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.
Files changed (89) hide show
  1. package/AGENTS.md +91 -153
  2. package/CHANGELOG.md +27 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +141 -81
  7. package/SECURITY.md +21 -0
  8. package/assets/screenshots/memory.png +0 -0
  9. package/assets/screenshots/menu.png +0 -0
  10. package/assets/screenshots/workspace.png +0 -0
  11. package/client.js +1348 -441
  12. package/cordis.patch.yml +1 -1
  13. package/docs/ASSETS.md +25 -0
  14. package/docs/DEPLOYMENT.md +102 -0
  15. package/docs/PRIVACY.md +32 -0
  16. package/docs/RELEASE_AUDIT.md +70 -0
  17. package/docs/RELEASING.md +94 -0
  18. package/docs/TEST_MATRIX.md +77 -0
  19. package/docs/assets-permissions.json +20 -0
  20. package/docs/tool-evidence.json +196 -0
  21. package/index.js +953 -270
  22. package/lib/asr.py +41 -35
  23. package/lib/background-output.mjs +64 -17
  24. package/lib/background-tools.mjs +1 -0
  25. package/lib/companion.mjs +43 -0
  26. package/lib/config-policy.mjs +20 -0
  27. package/lib/favops.py +9 -2
  28. package/lib/ids.mjs +41 -0
  29. package/lib/login-session.mjs +34 -0
  30. package/lib/login.mjs +5 -4
  31. package/lib/mcp.mjs +3 -2
  32. package/lib/memory-center.mjs +74 -0
  33. package/lib/mode.mjs +6 -7
  34. package/lib/model-relay.mjs +5 -4
  35. package/lib/open-directory.mjs +93 -0
  36. package/lib/pace.mjs +92 -12
  37. package/lib/persona.mjs +46 -0
  38. package/lib/product-status.mjs +123 -0
  39. package/lib/prompts.mjs +40 -5
  40. package/lib/pv-production.mjs +280 -0
  41. package/lib/pv-session.mjs +316 -0
  42. package/lib/pv.mjs +398 -306
  43. package/lib/pv.py +84 -22
  44. package/lib/pv_cover.py +108 -26
  45. package/lib/pv_editorial.py +336 -0
  46. package/lib/pv_verify.py +267 -0
  47. package/lib/python-env.mjs +61 -0
  48. package/lib/reply.mjs +10 -3
  49. package/lib/run-settings.mjs +6 -0
  50. package/lib/sticker-automation.mjs +5 -4
  51. package/lib/sticker.mjs +135 -25
  52. package/lib/tool-scope.mjs +65 -0
  53. package/lib/uvfetch.mjs +165 -153
  54. package/lib/verify.mjs +111 -127
  55. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  56. package/package.json +23 -12
  57. package/presets/deepmoyu.patch.yml +1 -1
  58. package/prompts/duty.md +4 -3
  59. package/screenshots.json +5 -0
  60. package/selfcheck.mjs +21 -6
  61. package/vendor/bilibili-mcp/requirements.in +9 -0
  62. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  63. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  64. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  65. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  66. package/tests/automation-routes.test.mjs +0 -183
  67. package/tests/automation.test.mjs +0 -79
  68. package/tests/background-tools.test.mjs +0 -182
  69. package/tests/background-window.test.mjs +0 -75
  70. package/tests/dup-cross-channel.test.mjs +0 -158
  71. package/tests/manual-browsing.test.mjs +0 -671
  72. package/tests/mcp-restart.test.mjs +0 -131
  73. package/tests/memory-chain.test.mjs +0 -413
  74. package/tests/model-relay.test.mjs +0 -116
  75. package/tests/persona.test.mjs +0 -67
  76. package/tests/pet-run-routes.test.mjs +0 -127
  77. package/tests/pet-run-settings.test.mjs +0 -38
  78. package/tests/pet-session.test.mjs +0 -125
  79. package/tests/pv-stage1.test.mjs +0 -203
  80. package/tests/sticker-routes.test.mjs +0 -141
  81. package/tests/sticker-upload-routes.test.mjs +0 -97
  82. package/tests/sticker-upload.test.mjs +0 -122
  83. package/tests/sticker.test.mjs +0 -169
  84. package/tests/test_guard_cross_channel.py +0 -141
  85. package/tests/test_pv_cover.py +0 -95
  86. package/tests/test_pv_stage1.py +0 -250
  87. package/tests/test_reply_pictures.py +0 -255
  88. package/tests/token-ledger.test.mjs +0 -78
  89. package/tests/verify_pv_sample.py +0 -91
@@ -1,116 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { createModelRelay, RELAY_PROVIDER, splitModelRoute, usageTokens } from '../lib/model-relay.mjs'
4
- import { createRelayAdapter } from '../lib/model-relay-adapter.mjs'
5
-
6
- const model = 'buddy/glm-5.3-flash'
7
- function fakeLlm(overrides = {}) {
8
- const calls = []
9
- return {
10
- calls,
11
- listProviders: () => [{ id: 'buddy', name: 'Buddy' }],
12
- async resolveModelInfo(provider, id) { return { provider, id, name: id, context: { contextWindow: 131072 }, defaultMaxTokens: 8192 } },
13
- async prepareCall(config, signal) {
14
- calls.push({ config, signal })
15
- return { config: { ...config, maxTokens: config.maxTokens || 8192 }, async *stream(options) {
16
- assert.equal(options.provider, 'buddy'); assert.equal(options.model, 'glm-5.3-flash')
17
- assert.equal(options.maxTokens, 8192)
18
- assert.equal(options.messages[0].source.provider, 'buddy')
19
- assert.equal(options.messages[0].source.model, 'glm-5.3-flash')
20
- assert.equal(options.attribution, undefined)
21
- yield { type: 'text-delta', index: 0, text: '小鱼找到一条!' }
22
- yield { type: 'tool-call-delta', index: 1, id: 'call-a', name: 'bili_search', argumentsDelta: '{}' }
23
- yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 20 } }
24
- yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 6, cacheReadTokens: 20, totalTokens: 36 } }
25
- yield { type: 'finish', reason: { kind: 'stop' } }
26
- } }
27
- },
28
- ...overrides,
29
- }
30
- }
31
- const messages = [{ role: 'assistant', source: { kind: 'model', provider: RELAY_PROVIDER, model, replayState: { ok: true } }, content: [{ type: 'text', text: 'old reply' }] }]
32
-
33
- test('parent relay streams the exact configured model with prepared defaults and final usage once', async () => {
34
- const usage = [], events = [], llm = fakeLlm()
35
- const relay = createModelRelay({ getLlm: () => llm })
36
- try {
37
- assert.equal(await relay.ready, true)
38
- const grant = relay.open(model, { onUsage: x => usage.push(x), onEvent: x => events.push(x) })
39
- const adapter = createRelayAdapter(grant.env)
40
- const info = await adapter.resolveModel(RELAY_PROVIDER, model)
41
- assert.equal(info.provider, RELAY_PROVIDER); assert.equal(info.id, model); assert.equal(info.context.contextWindow, 131072)
42
- const prepared = await adapter.prepareCall(RELAY_PROVIDER, model)
43
- const chunks = []
44
- for await (const chunk of prepared.stream({ provider: RELAY_PROVIDER, model, messages, attribution: { sessionId: 'not-forwarded' } })) chunks.push(chunk)
45
- assert.equal(chunks[0].text, '小鱼找到一条!')
46
- assert.deepEqual(llm.calls[0].config, { provider: 'buddy', model: 'glm-5.3-flash' })
47
- assert.deepEqual(usage, [{ input: 30, output: 6, total: 36 }])
48
- assert.ok(events.some(e => e.stage === 'tool' && /bili_search/.test(e.text)))
49
- assert.ok(events.some(e => e.stage === 'model-done'))
50
- assert.equal(adapter.providerRetryPolicy().maxRetries, 0)
51
- grant.revoke()
52
- await assert.rejects(adapter.resolveModel(RELAY_PROVIDER, model), /撤销或过期/)
53
- } finally { relay.close() }
54
- })
55
-
56
- test('capabilities reject browser origins, unauthorized use, route override, and unavailable parent adapters', async () => {
57
- const relay = createModelRelay({ getLlm: () => fakeLlm() })
58
- try {
59
- await relay.ready
60
- assert.throws(() => relay.open('other/model'), /没有注册/)
61
- const grant = relay.open(model)
62
- const url = grant.env.BILI_DUTY_RELAY_URL, auth = 'Bearer ' + grant.env.BILI_DUTY_RELAY_CAPABILITY
63
- assert.equal((await fetch(url, { method: 'POST', body: '{}' })).status, 403)
64
- assert.equal((await fetch(url, { method: 'POST', headers: { authorization: auth, origin: 'http://127.0.0.1' }, body: '{}' })).status, 403)
65
- const adapter = createRelayAdapter(grant.env)
66
- await assert.rejects(adapter.resolveModel(RELAY_PROVIDER, 'other/model'), /路线/)
67
- assert.throws(() => createRelayAdapter({ ...grant.env, BILI_DUTY_RELAY_URL: 'https://remote.invalid/call' }), /本机/)
68
- grant.revoke()
69
- } finally { relay.close() }
70
- })
71
-
72
- test('revoking a run aborts active model IO, never retries and closes the stream', async () => {
73
- let started, aborted
74
- const begin = new Promise(resolve => { started = resolve }), done = new Promise(resolve => { aborted = resolve })
75
- const llm = fakeLlm({ async prepareCall(config, signal) {
76
- return { config, async *stream() {
77
- started()
78
- await new Promise(resolve => { signal.addEventListener('abort', () => { aborted(); resolve() }, { once: true }) })
79
- signal.throwIfAborted()
80
- } }
81
- } })
82
- const relay = createModelRelay({ getLlm: () => llm })
83
- try {
84
- await relay.ready
85
- const grant = relay.open(model), adapter = createRelayAdapter(grant.env)
86
- const reading = (async () => { for await (const _ of adapter.stream({ provider: RELAY_PROVIDER, model, messages: [] })) {} })()
87
- await begin; grant.revoke(); await done
88
- await assert.rejects(reading)
89
- } finally { relay.close() }
90
- })
91
-
92
- test('terminal errors are shown and partial measured usage is not lost on interruption', async () => {
93
- const usage = [], events = []
94
- const llm = fakeLlm({ async prepareCall(config) { return { config, async *stream() {
95
- yield { type: 'usage', usage: { inputTokens: 11, outputTokens: 2 } }
96
- yield { type: 'finish', reason: { kind: 'error', failure: { code: 'NO_ADAPTER', message: 'fixture failure' } } }
97
- } } } })
98
- const relay = createModelRelay({ getLlm: () => llm })
99
- try {
100
- await relay.ready
101
- const grant = relay.open(model, { onUsage: x => usage.push(x), onEvent: x => events.push(x) })
102
- const adapter = createRelayAdapter(grant.env)
103
- for await (const _ of adapter.stream({ provider: RELAY_PROVIDER, model, messages: [] })) {}
104
- assert.deepEqual(usage, [{ input: 11, output: 2, total: 13 }])
105
- assert.ok(events.some(e => e.stage === 'model-error' && /fixture/.test(e.text)))
106
- assert.ok(!events.some(e => e.stage === 'model-done'))
107
- } finally { relay.close() }
108
- })
109
-
110
- test('model routes and usage validation never invent a route or estimate missing usage', () => {
111
- assert.deepEqual(splitModelRoute(' buddy/path/model '), { provider: 'buddy', model: 'path/model' })
112
- for (const route of ['', 'buddy', '/model', 'buddy/', 'buddy/model with space']) assert.throws(() => splitModelRoute(route))
113
- assert.equal(usageTokens(undefined), null)
114
- assert.equal(usageTokens({ inputTokens: 2 }), null)
115
- assert.deepEqual(usageTokens({ inputTokens: 0, outputTokens: 0 }), { input: 0, output: 0, total: 0 })
116
- })
@@ -1,67 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { DAFEIYU_REPLY_GUIDE, DAFEIYU_REPLY_STYLE_RULES } from '../lib/persona.mjs'
4
- import { CHARACTER_COMPASS } from '../lib/mode.mjs'
5
- import { buildFeiyuObjective, buildLiveBrief } from '../lib/prompts.mjs'
6
- import { learnAdd, learnDecide, learnInject } from '../lib/learn.mjs'
7
-
8
- const base = {
9
- cfg: { keywords: [], perDay: 5, momentPerDay: 1, dmMode: 'off', autoComment: true, replyToComments: true, autoReplyDm: false, autoMoment: false, reportLevel: 'brief' },
10
- stateDir: 'C:/fixture/bili-duty',
11
- outDir: 'C:/fixture/bili-duty/out',
12
- digestDir: 'C:/fixture/bili-duty/digest',
13
- charter: 'C:/fixture/bili-duty/duty.md',
14
- day: '2026-10-27',
15
- }
16
-
17
- test('persona guide encodes natural, content-first fish replies without a catchphrase recipe', () => {
18
- assert.match(DAFEIYU_REPLY_GUIDE, /先接住/)
19
- assert.match(DAFEIYU_REPLY_GUIDE, /具体/)
20
- assert.match(DAFEIYU_REPLY_GUIDE, /客服/)
21
- assert.match(DAFEIYU_REPLY_GUIDE, /固定口癖/)
22
- assert.equal(DAFEIYU_REPLY_STYLE_RULES.short, true)
23
- assert.equal(DAFEIYU_REPLY_STYLE_RULES.contentFirst, true)
24
- assert.equal(DAFEIYU_REPLY_STYLE_RULES.variableSelfReference, true)
25
- assert.equal(DAFEIYU_REPLY_STYLE_RULES.variableEmotion, true)
26
- assert.equal(DAFEIYU_REPLY_STYLE_RULES.noCustomerServiceTone, true)
27
- assert.equal(DAFEIYU_REPLY_STYLE_RULES.noFixedCatchphrase, true)
28
- assert.equal(DAFEIYU_REPLY_STYLE_RULES.maxKaomojiPerSentence, 1)
29
- assert.doesNotMatch(DAFEIYU_REPLY_GUIDE, /示例句|固定回复|每句都/)
30
- })
31
-
32
- test('session objective carries the shared reply guide and stays non-template', () => {
33
- const prompt = buildFeiyuObjective(base)
34
- assert.ok(prompt.includes(DAFEIYU_REPLY_GUIDE))
35
- assert.match(prompt, /先接住对方或内容里最具体的那一下/)
36
- assert.match(prompt, /不要复述字幕、评论原话/)
37
- assert.match(prompt, /不要写成客服/)
38
- assert.doesNotMatch(prompt, /每条.*本鱼.*呜/)
39
- })
40
-
41
- test('live brief keeps the conversational boundary and does not turn into a report template', () => {
42
- const prompt = buildLiveBrief({ ...base, picked: 1, moments: 0 })
43
- assert.match(prompt, /输出像聊天/)
44
- assert.match(prompt, /别说“本轮完成”/)
45
- assert.match(prompt, /为您总结/)
46
- })
47
-
48
- test('approved learning remains reference-only while persona guidance stays authoritative', () => {
49
- let ledger = {}
50
- const added = learnAdd(ledger, { kind: 'style', text: '先说当下那一下,再补一句自己的反应', from: 'fixture-video' })
51
- ledger = added.ledger
52
- assert.equal(learnInject(ledger, { mode: 'dafeiyu', budget: 1200 }).text, '')
53
- const decided = learnDecide(ledger, added.id, 'approve', '具体、可复用、来源清楚', '2026-10-27T00:00:00.000Z')
54
- ledger = decided.ledger
55
- const learned = learnInject(ledger, { mode: 'dafeiyu', budget: 1200 })
56
- assert.match(learned.text, /先说当下那一下/)
57
- const prompt = buildFeiyuObjective(base)
58
- assert.ok(prompt.includes(DAFEIYU_REPLY_GUIDE))
59
- assert.match(learned.text, /只是参考,不是必须照做/)
60
- })
61
-
62
- test('existing compass still keeps the fish identity boundary', () => {
63
- assert.equal(typeof CHARACTER_COMPASS, 'string')
64
- assert.ok(CHARACTER_COMPASS.includes('你是鲸鱼摸鱼'))
65
- assert.ok(CHARACTER_COMPASS.includes('不报型号'))
66
- assert.ok(CHARACTER_COMPASS.includes('公开评论、动态和私信仍各按授权与节奏开关'))
67
- })
@@ -1,127 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
- import { tmpdir } from 'node:os'
5
- import { join } from 'node:path'
6
- import { EventEmitter } from 'node:events'
7
- import { createRequire, syncBuiltinESMExports } from 'node:module'
8
- import { apply } from '../index.js'
9
-
10
- test('pet routes isolate defaults, enforce session budgets and safely stop background', async () => {
11
- const root = mkdtempSync(join(tmpdir(), 'pet-run-test-'))
12
- const home = join(root, 'bili-duty'); mkdirSync(home)
13
- const dummy = join(root, 'never-executed.js'); writeFileSync(dummy, '// never executed: spawn is mocked')
14
- writeFileSync(join(home, 'config.json'), JSON.stringify({ dshBin: dummy, modelId: 'legacy/model', tokenBudget: 999999, maxRounds: 64, sessionModelId: 'saved/session', sessionTokenBudget: 9000, sessionMaxRounds: 16, sessionMinutes: 0, backgroundModelId: 'saved/background', backgroundTokenBudget: 7000, backgroundMinutes: 45 }))
15
- const workspace = { id: 'current-workspace', path: root, sessionIds: ['fish-b'] }, createRequests = []
16
- const permissionState = new Map()
17
- const session = id => ({ header: { agentPreset: 'dafeiyu', cwd: root }, append(type, data) {
18
- const state = permissionState.get(id) || {}
19
- if (type === 'sandbox/mode') state.sandbox = data.mode
20
- if (type === 'approval/policy') state.approval = data.policy
21
- permissionState.set(id, state)
22
- } })
23
- const a = { id: 'fish-a', status: 'running', session: session('fish-a') }
24
- const b = { id: 'fish-b', status: 'idle', session: session('fish-b') }
25
- permissionState.set(a.id, { sandbox: 'workspace-write', approval: 'ask' })
26
- permissionState.set(b.id, { sandbox: 'danger-full-access', approval: 'never' })
27
- const agents = [a, b], goalsById = new Map(), selections = new Map(agents.map(x => [x.id, { current: { provider: 'default', model: 'model' } }]))
28
- const usage = new Map(agents.map(x => [x.session, 0]))
29
- const events = new Map(), cleanup = [], timeouts = [], children = [], spawned = []
30
- let handler, seq = 0
31
- const goals = {
32
- get: agent => goalsById.get(agent.id),
33
- create(agent, { objective, maxGoalRounds }) { const goal = { id: 'g' + ++seq, objective, maxGoalRounds, roundsStarted: 1, revision: 1, phase: 'active', activation: 'armed' }; goalsById.set(agent.id, goal); return goal },
34
- pause(agent) { const g = goalsById.get(agent.id); g.phase = 'paused'; g.activation = 'disarmed'; return g },
35
- resume(agent) { const g = goalsById.get(agent.id); g.phase = 'active'; g.activation = 'armed'; return g },
36
- clear(agent) { const g = goalsById.get(agent.id); goalsById.delete(agent.id); return g },
37
- }
38
- const services = {
39
- dshHomePath: (...parts) => join(root, ...parts), goals,
40
- llm: { listProviders: () => ['explicit', 'saved', 'other'].map(id => ({ id })) },
41
- agents: { get: id => agents.find(a => a.id === id), list: () => agents },
42
- sessionController: { create: async request => { createRequests.push(request); workspace.sessionIds.push(a.id); return { sessionId: a.id } }, agents: { selectionFor: agent => selections.get(agent.id) } },
43
- workspaceRegistry: { list: () => [workspace], get: id => id === workspace.id ? workspace : undefined, resolveByPath: async path => path === root ? workspace : undefined },
44
- sessionProjections: { stateOf(session, key) { if (key === 'agentPreset') return 'dafeiyu'; if (key === 'tokenUsage') return { totals: { uncachedInputTokens: usage.get(session) || 0 } }; if (key === 'permissions') return permissionState.get(agents.find(a => a.session === session)?.id) } },
45
- }
46
- const ctx = { get: key => services[key], logger: { info() {} }, tools: { register: tool => tool }, settings: { describe: () => [] },
47
- on(name, fn) { events.set(name, fn); return () => events.delete(name) },
48
- effect(fn) { const c = fn(); if (typeof c === 'function') cleanup.push(c); return c },
49
- webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
50
- }
51
- const actualTimeout = globalThis.setTimeout, actualClearTimeout = globalThis.clearTimeout, actualInterval = globalThis.setInterval, actualClearInterval = globalThis.clearInterval
52
- globalThis.setTimeout = (fn, ms) => { const timer = { fn, ms, cleared: false, unref() {} }; timeouts.push(timer); return timer }
53
- globalThis.clearTimeout = timer => { if (timer) timer.cleared = true }
54
- globalThis.setInterval = () => ({ unref() {} }); globalThis.clearInterval = () => {}
55
- const cp = createRequire(import.meta.url)('node:child_process'), actualSpawn = cp.spawn
56
- cp.spawn = (command, args, opts) => {
57
- spawned.push({ command, args, opts })
58
- const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.exitCode = null; child.killed = false
59
- child.kill = () => { child.killed = true; return true }; children.push(child); return child
60
- }; syncBuiltinESMExports()
61
- async function request(path, body, method = 'POST') {
62
- const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = method; req.headers = {}; req.destroy = () => {}
63
- const res = { headersSent: false, writableEnded: false, writeHead(code) { this.code = code; this.headersSent = true }, end(text) { this.body = JSON.parse(text); this.writableEnded = true } }
64
- const result = handler(req, res)
65
- if (method === 'POST') { req.emit('data', Buffer.from(JSON.stringify(body || {}))); req.emit('end') }
66
- await result; return { code: res.code, ...res.body }
67
- }
68
- try {
69
- apply(ctx, {})
70
- await new Promise(resolve => setImmediate(resolve))
71
- const created = await request('/pet/session-new', { sourceSessionId: b.id, workspaceId: workspace.id })
72
- assert.equal(created.ok, true); assert.equal(created.sessionId, a.id); assert.equal(created.workspaceId, workspace.id)
73
- assert.deepEqual(createRequests, [{ agentPreset: 'dafeiyu', workspaceId: workspace.id }])
74
- assert.equal(created.permissions.sandbox, 'danger-full-access'); assert.equal(created.permissions.approval, 'never')
75
- assert.ok(workspace.sessionIds.includes(a.id))
76
- const started = await request('/pet/start', { mode: 'session', sessionId: a.id, model: 'explicit/session', tokenBudget: 100, maxRounds: 23, durationMinutes: 1 })
77
- assert.equal(started.ok, true); assert.equal(started.maxRounds, 23); assert.equal(started.model, 'explicit/session')
78
- assert.equal(started.tokenBudget, 100); assert.equal(started.durationEnforcement, 'wall-clock-pause')
79
- const firstDeadline = timeouts.find(t => t.ms > 59000 && t.ms <= 60000)
80
- assert.ok(firstDeadline)
81
- let state = await request('/feiyu', {}, 'GET'); assert.equal(state.sessionId, a.id); assert.equal(state.tokens.budget, 100)
82
- const saved = await request('/config', { backgroundModelId: 'other/background', backgroundTokenBudget: 4000, backgroundMinutes: 999 })
83
- assert.equal(saved.config.backgroundMinutes, 180); assert.ok(saved.clamped.some(x => x.key === 'backgroundMinutes'))
84
- assert.equal(saved.config.sessionModelId, 'saved/session'); assert.equal(saved.config.modelId, 'legacy/model')
85
- state = await request('/feiyu', {}, 'GET'); assert.equal(state.tokens.budget, 100)
86
- usage.set(a.session, 101); events.get('agent/status')({ agent: a, status: 'idle' })
87
- const fuse = timeouts.findLast(t => t.ms === 1200); assert.ok(fuse); fuse.fn()
88
- assert.equal(goals.get(a).phase, 'paused')
89
- await request('/feiyu', { action: 'clear', sessionId: a.id })
90
- const second = await request('/pet/start', { mode: 'session', sessionId: a.id, tokenBudget: 0, maxRounds: 7, durationMinutes: 2 })
91
- assert.equal(second.ok, true); const secondGoal = goals.get(a).id
92
- firstDeadline.fn(); fuse.fn(); assert.equal(goals.get(a).id, secondGoal); assert.equal(goals.get(a).phase, 'active', 'stale deadline and token timers must not pause new run')
93
- await request('/pet/start', { mode: 'session', sessionId: b.id, tokenBudget: 0, durationMinutes: 0 })
94
- await request('/feiyu', { action: 'stop', sessionId: a.id }); assert.equal(goals.get(a).phase, 'paused'); assert.equal(goals.get(b).phase, 'active')
95
- const secondDeadline = timeouts.find(t => t.ms > 119000 && t.ms <= 120000)
96
- await request('/feiyu', { action: 'resume', sessionId: a.id }); secondDeadline.fn(); assert.equal(goals.get(a).phase, 'paused', 'wall clock timer must work while agent is running with no tool events')
97
- const blocked = await request('/pet/start', { mode: 'background', model: 'explicit/background', tokenBudget: 6000, durationMinutes: 3 })
98
- assert.equal(blocked.started, false, 'session fish still swimming ⇒ background start must refuse (double-draw guard)')
99
- await request('/feiyu', { action: 'stop', sessionId: b.id }); assert.equal(goals.get(b).phase, 'paused')
100
- const bg = await request('/pet/start', { mode: 'background', model: 'explicit/background', tokenBudget: 6000, durationMinutes: 3 })
101
- assert.equal(bg.started, true); assert.equal(bg.tokenBudgetEnforcement, 'advisory'); assert.equal(bg.backgroundRun.durationMinutes, 3)
102
- assert.equal(spawned.length, 1); assert.match(spawned[0].args.at(-1), /6000/)
103
- const overlay = readFileSync(join(home, 'headless-overlay.yml'), 'utf8'); assert.match(overlay, /provider: 'dafeiyu-parent'/); assert.match(overlay, /model: 'explicit\/background'/)
104
- assert.match(overlay, /model-relay-adapter\.mjs/)
105
- assert.doesNotMatch(overlay, /BILI_DUTY_RELAY_CAPABILITY/)
106
- assert.match(spawned[0].opts.env.BILI_DUTY_RELAY_CAPABILITY, /^[a-f0-9]{64}$/)
107
- const config = await request('/config', {}, 'GET'); assert.equal(config.config.modelId, 'legacy/model'); assert.equal(config.config.backgroundModelId, 'other/background')
108
- assert.equal((await request('/feiyu', {}, 'GET')).sessionId, b.id, 'untargeted control prefers an active fish over paused sessions')
109
- const stopped = await request('/pet/stop', {}); assert.equal(stopped.ok, true); assert.equal(children[0].killed, true); assert.equal(goals.get(b).phase, 'paused', 'pet/stop must not touch session fish')
110
- assert.equal((await request('/pet/start', { mode: 'background' })).busy, true, 'do not overlap workers until actual exit')
111
- const bgDeadline = timeouts.find(t => t.ms > 179000 && t.ms <= 180000); assert.ok(bgDeadline)
112
- children[0].emit('close', null)
113
- const again = await request('/pet/start', { mode: 'background', durationMinutes: 4 }); assert.ok(again.started)
114
- bgDeadline.fn(); assert.equal(children[1].killed, false, 'old deadline cannot kill replacement background run')
115
- const newDeadline = timeouts.find(t => t.ms > 239000 && t.ms <= 240000); assert.ok(newDeadline); newDeadline.fn(); assert.equal(children[1].killed, true)
116
- children[1].emit('close', null)
117
- assert.equal((await request('/status', {}, 'GET')).backgroundRun.stopReason, 'time-limit')
118
- const logs = await request('/pet/log?n=500', {}, 'GET'); assert.ok(logs.entries.some(e => e.type === 'bg-done' && e.code === null && e.reason === 'user'))
119
- assert.equal((await request('/pet/start', { mode: 'bad' })).code, 400)
120
- assert.equal((await request('/pet/start', { mode: 'session' })).code, 400)
121
- assert.equal((await request('/pet/start', { mode: 'session', sessionId: 'missing' })).ok, false)
122
- } finally {
123
- for (const fn of cleanup.reverse()) { try { fn() } catch {} }
124
- globalThis.setTimeout = actualTimeout; globalThis.clearTimeout = actualClearTimeout; globalThis.setInterval = actualInterval; globalThis.clearInterval = actualClearInterval
125
- cp.spawn = actualSpawn; syncBuiltinESMExports(); rmSync(root, { recursive: true, force: true })
126
- }
127
- })
@@ -1,38 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { migrateRunSettings, clampRunSettings, resolveRunOptions, scheduleRunDeadline } from '../lib/run-settings.mjs'
4
-
5
- test('legacy values only migrate missing mode keys', () => {
6
- const migrated = migrateRunSettings({ modelId: 'old/model', tokenBudget: 120000, maxRounds: 12, sessionModelId: '', backgroundTokenBudget: 0 })
7
- assert.equal(migrated.sessionModelId, '')
8
- assert.equal(migrated.backgroundModelId, 'old/model')
9
- assert.equal(migrated.sessionTokenBudget, 120000)
10
- assert.equal(migrated.backgroundTokenBudget, 0)
11
- assert.equal(migrated.sessionMaxRounds, 12)
12
- assert.equal(migrated.backgroundMinutes, 45)
13
- })
14
-
15
- test('mode options remain independent and explicit rounds are not inferred from tokens', () => {
16
- const cfg = { sessionModelId: 's/m', sessionTokenBudget: 10, sessionMaxRounds: 19, sessionMinutes: 0, backgroundModelId: 'b/m', backgroundTokenBudget: 90000, backgroundMinutes: 17 }
17
- assert.deepEqual(resolveRunOptions(cfg, 'session', { tokenBudget: 5000000 }).maxRounds, 19)
18
- const session = resolveRunOptions(cfg, 'session', { model: 'x/y', maxRounds: 7, durationMinutes: 20 })
19
- assert.equal(session.model, 'x/y'); assert.equal(session.maxRounds, 7); assert.equal(session.tokenBudget, 10)
20
- const bg = resolveRunOptions(cfg, 'background')
21
- assert.equal(bg.model, 'b/m'); assert.equal(bg.durationMinutes, 17)
22
- assert.equal(bg.tokenBudgetEnforcement, 'advisory'); assert.equal(bg.maxRoundsEnforcement, 'single-run')
23
- assert.equal(cfg.sessionModelId, 's/m')
24
- })
25
-
26
- test('new config bounds clamp to documented integer ranges', () => {
27
- assert.deepEqual(clampRunSettings({ sessionMinutes: -1, backgroundMinutes: 999, sessionMaxRounds: 2000, sessionTokenBudget: 9999999, backgroundTokenBudget: -1 }), { sessionMinutes: 0, backgroundMinutes: 180, sessionMaxRounds: 1000, sessionTokenBudget: 5000000, backgroundTokenBudget: 0 })
28
- })
29
-
30
- test('deadline timer works without a tool or idle event and is unreferenced', () => {
31
- let callback, delay, unref = false, expired = false
32
- const timer = { unref() { unref = true } }
33
- const timers = { setTimeout(fn, ms) { callback = fn; delay = ms; return timer } }
34
- assert.equal(scheduleRunDeadline(0, () => {}, timers), null)
35
- assert.equal(scheduleRunDeadline(Date.now() + 5000, () => { expired = true }, timers), timer)
36
- assert.ok(delay > 4000 && delay <= 5000); assert.ok(unref)
37
- callback(); assert.ok(expired)
38
- })
@@ -1,125 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { createPetSession } from '../lib/pet-session.mjs'
4
-
5
- const cwd = 'D:\\鱼的工作区', otherCwd = 'D:\\另一个工作区'
6
- function fixture({ sourceMode = 'danger-full-access', sourceApproval = 'never', sourcePreset = 'danger-full-access', attached = true } = {}) {
7
- const calls = [], setCalls = [], sessions = new Map(), agents = new Map()
8
- const specs = { 'workspace-write': { sandbox: 'workspace-write', approval: 'ask' }, 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' }, auto: { sandbox: 'danger-full-access', approval: 'ask' } }
9
- const makeSession = (id, path, sandbox, approval, preset) => {
10
- const events = [], state = { sandbox, approval, preset }
11
- const session = { id, header: { cwd: path }, events, state,
12
- append(type, data) { events.push({ type, data }); if (type === 'sandbox/mode') state.sandbox = data.mode; if (type === 'approval/policy') state.approval = data.policy; if (type === 'permission/preset') state.preset = data.preset } }
13
- sessions.set(id, session); agents.set(id, { id, session }); return session
14
- }
15
- const source = makeSession('source', cwd, sourceMode, sourceApproval, sourcePreset)
16
- const workspace = { id: 'fish-workspace', path: cwd, sessionIds: attached ? ['source'] : [] }
17
- const other = { id: 'other-workspace', path: otherCwd, sessionIds: [] }, workspaces = [workspace, other]
18
- const services = {
19
- agents: { get: id => agents.get(id) }, sessions: { get: id => sessions.get(id) },
20
- workspaceRegistry: { get: id => workspaces.find(x => x.id === id), list: () => workspaces,
21
- resolveByPath: async path => workspaces.find(x => x.path === path) },
22
- sandboxPolicy: { resolve: ({ session }) => ({ mode: session.state.sandbox, workspaceRoot: session.header.cwd }) },
23
- approval: { effectivePolicy: session => session.state.approval },
24
- sessionProjections: { stateOf: session => session.state },
25
- permissionPresets: {
26
- current(session) { const state = session.state, spec = specs[state.preset]; return spec?.sandbox === state.sandbox && (spec.approval === state.approval || state.preset === 'auto' && state.approval === 'never') ? state.preset : Object.keys(specs).find(key => specs[key].sandbox === state.sandbox && specs[key].approval === state.approval) ?? 'custom' },
27
- resolve: key => { if (!specs[key]) throw new Error('unknown preset'); return specs[key] },
28
- set(session, preset) { setCalls.push({ session: session.id, preset }); const spec = specs[preset]; session.append('permission/preset', { preset }); session.append('sandbox/mode', { mode: spec.sandbox }); session.append('approval/policy', { policy: spec.approval }) },
29
- },
30
- }
31
- const ctx = { get: key => services[key] }
32
- const controller = {
33
- async create(request) {
34
- calls.push(request)
35
- assert.deepEqual(Object.keys(request).sort(), ['agentPreset', request.workspaceId ? 'workspaceId' : 'cwd'].sort(), 'only actual Host SessionCreateRequest fields are forwarded')
36
- assert.equal(request.agentPreset, 'dafeiyu')
37
- const owner = services.workspaceRegistry?.get(request.workspaceId), path = owner?.path ?? request.cwd
38
- const session = makeSession('created-' + calls.length, path, 'workspace-write', 'ask', 'workspace-write')
39
- if (owner) owner.sessionIds.push(session.id)
40
- return { sessionId: session.id, agentPreset: 'dafeiyu' }
41
- },
42
- }
43
- return { ctx, controller, calls, setCalls, sessions, agents, services, source, workspace, workspaces }
44
- }
45
-
46
- test('pet session belongs to selected Workspace and inherits full access without changing defaults', async () => {
47
- const f = fixture()
48
- const result = await createPetSession(f.ctx, f.controller, { sourceSessionId: 'source', workspaceId: 'fish-workspace' }, { fallbackCwd: otherCwd })
49
- assert.deepEqual(f.calls, [{ agentPreset: 'dafeiyu', workspaceId: 'fish-workspace' }])
50
- assert.ok(f.workspace.sessionIds.includes(result.sessionId))
51
- assert.equal(f.sessions.get(result.sessionId).header.cwd, cwd)
52
- assert.deepEqual(result.permissions, { preset: 'danger-full-access', sandbox: 'danger-full-access', approval: 'never', inherited: true })
53
- assert.deepEqual(f.setCalls, [{ session: result.sessionId, preset: 'danger-full-access' }])
54
- assert.deepEqual(f.source.events, [], 'inheritance never modifies the source')
55
- })
56
-
57
- test('pet session infers ownership from source membership or canonical cwd, keeping restrictive permissions', async () => {
58
- for (const attached of [true, false]) {
59
- const f = fixture({ attached, sourceMode: 'workspace-write', sourceApproval: 'ask', sourcePreset: 'workspace-write' })
60
- const result = await createPetSession(f.ctx, f.controller, { sourceSessionId: 'source', sandbox: 'danger-full-access', approval: 'never' })
61
- assert.equal(f.calls[0].workspaceId, 'fish-workspace')
62
- assert.equal(result.permissions.sandbox, 'workspace-write'); assert.equal(result.permissions.approval, 'ask')
63
- }
64
- })
65
-
66
- test('pet session inherits custom sandbox/approval combination and delegated Auto never exactly', async () => {
67
- for (const options of [
68
- { sourceMode: 'read-only', sourceApproval: 'never', sourcePreset: 'custom' },
69
- { sourceMode: 'danger-full-access', sourceApproval: 'never', sourcePreset: 'auto' },
70
- ]) {
71
- const f = fixture(options)
72
- const result = await createPetSession(f.ctx, f.controller, { sourceSessionId: 'source' })
73
- const target = f.sessions.get(result.sessionId)
74
- assert.equal(result.permissions.sandbox, options.sourceMode); assert.equal(result.permissions.approval, options.sourceApproval)
75
- assert.equal(f.setCalls.length, options.sourcePreset === 'auto' ? 1 : 0, 'Auto retains its identity and admission check; custom knobs have no selectable preset')
76
- if (options.sourcePreset === 'auto') assert.equal(result.permissions.preset, 'auto')
77
- assert.ok(target.events.some(x => x.type === 'sandbox/mode' && x.data.mode === options.sourceMode))
78
- assert.ok(target.events.some(x => x.type === 'approval/policy' && x.data.policy === 'never'))
79
- assert.ok(target.events.every(x => x.type !== 'permission/preset' || x.data.preset !== 'custom'))
80
- }
81
- })
82
-
83
- test('pet session reads scoped effective permissions and can resolve a cold selected session', async () => {
84
- const f = fixture({ sourceMode: 'read-only', sourceApproval: 'ask', sourcePreset: 'custom' })
85
- const scoped = { ...f.services, sandboxPolicy: { resolve: () => ({ mode: 'danger-full-access' }) }, approval: { effectivePolicy: () => 'never' }, permissionPresets: { current: () => 'custom' } }
86
- const agent = f.agents.get('source'); agent.ctx = { get: key => scoped[key] }
87
- f.agents.delete('source'); f.sessions.delete('source')
88
- f.controller.resolveAgent = async id => { assert.equal(id, 'source'); return { agent } }
89
- const result = await createPetSession(f.ctx, f.controller, { sourceSessionId: 'source' })
90
- assert.equal(result.permissions.sandbox, 'danger-full-access'); assert.equal(result.permissions.approval, 'never')
91
- })
92
-
93
- test('pet session context errors do not silently create a stray session', async () => {
94
- for (const body of [{ sourceSessionId: 'missing' }, { workspaceId: 'missing' }, { sourceSessionId: 'source', workspaceId: 'other-workspace' }, { sourceSessionId: {} }]) {
95
- const f = fixture()
96
- await assert.rejects(createPetSession(f.ctx, f.controller, body))
97
- assert.equal(f.calls.length, 0)
98
- }
99
- const f = fixture()
100
- await assert.rejects(createPetSession(f.ctx, f.controller, {}, { fallbackCwd: 'D:\\unowned' }), /无法确定当前工作区/)
101
- assert.equal(f.calls.length, 0)
102
- })
103
-
104
- test('legacy pet request uses matching directory or sole Workspace and retains Host defaults', async () => {
105
- const f = fixture()
106
- const matched = await createPetSession(f.ctx, f.controller, {}, { fallbackCwd: cwd })
107
- assert.equal(matched.workspaceId, 'fish-workspace'); assert.equal(matched.permissions.inherited, false)
108
- assert.equal(matched.permissions.sandbox, 'workspace-write')
109
- f.workspaces.splice(1)
110
- const single = await createPetSession(f.ctx, f.controller, {}, { fallbackCwd: otherCwd })
111
- assert.equal(single.workspaceId, 'fish-workspace')
112
- f.services.workspaceRegistry = undefined
113
- const withoutRegistry = await createPetSession(f.ctx, f.controller, { sourceSessionId: 'source' })
114
- assert.deepEqual(f.calls.at(-1), { agentPreset: 'dafeiyu', cwd })
115
- assert.equal(withoutRegistry.permissions.sandbox, 'danger-full-access')
116
- })
117
-
118
- test('permission inheritance failure returns created identity and cannot be mistaken for a startable success', async () => {
119
- const f = fixture()
120
- f.services.permissionPresets.set = () => {}
121
- f.services.sandboxPolicy = { resolve: ({ session }) => ({ mode: session.id === 'source' ? 'danger-full-access' : 'workspace-write' }) }
122
- await assert.rejects(createPetSession(f.ctx, f.controller, { sourceSessionId: 'source' }), error => {
123
- assert.equal(error.sessionId, 'created-1'); assert.match(error.message, /权限继承未生效/); return true
124
- })
125
- })