dsh-agent-board 1.7.4 → 1.7.6

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.
@@ -0,0 +1,209 @@
1
+ // dsh-agent-board — 无头冒烟链路 + mock-ctx harness 各面独立用例(task-muxpfon9 无头基建卡1)
2
+ // 本文件同时证明两件事:
3
+ // ① test/helpers/mock-ctx.mjs 这套无头宿主基建的每一面都可独立工作——
4
+ // fs 临时目录隔离 / 虚拟时钟推进 / subagents 剧本化(一次性 run + continuable 句柄)/
5
+ // agentEvents 事件发射 / webServer 路由直调 / agents 剧本 / tools·systemPrompt·llm 记录器;
6
+ // ② 冒烟链路:真实 import ../index.mjs → apply(env.ctx) → 建一张卡 → 虚拟时钟推进 →
7
+ // 断言派发发生(subagents 剧本收到 continuable spawn)→ emitStatus 事件结算 → 断点续跑 sendMessage。
8
+ // 约束:不改 index.mjs 任何行为;看板文件落在 os.tmpdir 下的独立临时 HOME,cleanup 全清。
9
+ // 注意:node:test 顶层用例默认串行——每个用例自建 env、finally 里 cleanup(HOME env 是进程级,严禁并行env)。
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
16
+ import * as plugin from '../index.mjs'
17
+
18
+ // ===== harness 面①:fs —— 临时 HOME 接管 / 目录隔离 / cleanup 恢复 =====
19
+ test('harness·fs 面:临时 HOME 接管+隔离,cleanup 恢复 env 并递归删目录', async () => {
20
+ var hadHome = Object.prototype.hasOwnProperty.call(process.env, 'HOME')
21
+ var prevHome = process.env.HOME
22
+ var env1 = createMockCtx()
23
+ assert.equal(process.env.HOME, env1.home, '创建后 HOME 指向该 env 的临时目录(boardDirName 每次现读 env)')
24
+ assert.ok(env1.home.indexOf(os.tmpdir()) === 0, '临时目录落在 os.tmpdir 下')
25
+ assert.ok(fs.existsSync(env1.home), '临时目录真实存在(真写 fs)')
26
+ var env2 = createMockCtx()
27
+ assert.notEqual(env1.home, env2.home, '每个 env 各自独立临时目录')
28
+ fs.writeFileSync(path.join(env1.home, 'marker.txt'), 'x', 'utf8')
29
+ assert.ok(!fs.existsSync(path.join(env2.home, 'marker.txt')), '目录间互不污染')
30
+ await env2.cleanup() // LIFO 纪律:后建的先清,HOME 回到 env1
31
+ assert.equal(process.env.HOME, env1.home, 'cleanup 按 LIFO 恢复上一层 HOME')
32
+ await env1.cleanup()
33
+ assert.ok(!fs.existsSync(env1.home), 'cleanup 递归删除临时目录')
34
+ if (hadHome) assert.equal(process.env.HOME, prevHome, 'HOME env 恢复创建前的原值')
35
+ else assert.ok(!Object.prototype.hasOwnProperty.call(process.env, 'HOME'), '原本无 HOME 则恢复为未设置')
36
+ })
37
+
38
+ // ===== harness 面②:虚拟时钟 —— 到期触发 / dispose 取消 / interval 续挂 =====
39
+ test('harness·时钟面:advance 按到期顺序触发,dispose 真取消,interval 自动续挂', async () => {
40
+ var env = createMockCtx()
41
+ try {
42
+ var fired = []
43
+ var cancel = env.ctx.timer.timeout(function () { fired.push('disposed') }, 10)
44
+ cancel() // 回调式的 dispose 闭包:真清掉,到期不触发
45
+ env.ctx.timer.timeout(function () { fired.push('cb50') }, 50)
46
+ var p100 = env.ctx.timer.timeout(100).then(function () { fired.push('p100') }) // promise 形态(kickCycle/去抖/聚合同款用法)
47
+ var iv = 0
48
+ var stopIv = env.ctx.timer.interval(function () { iv++ }, 30)
49
+ assert.equal(env.clock.now(), 0, '虚拟时钟从 0 起算')
50
+ await env.clock.advance(40)
51
+ assert.deepEqual(fired, [], '40ms 内无到期(10 已 dispose,50/100 未到点)')
52
+ await env.clock.advance(60) // → now=100
53
+ assert.deepEqual(fired, ['cb50', 'p100'], '按到期顺序触发(回调式 50 先于 promise 式 100)')
54
+ assert.equal(iv, 3, 'interval 30ms 在 100ms 窗口内触发 3 次(30/60/90)并自动续挂')
55
+ assert.equal(env.clock.now(), 100)
56
+ stopIv()
57
+ await env.clock.advance(1000)
58
+ assert.equal(iv, 3, 'interval dispose 后不再触发')
59
+ assert.equal(env.clock.pendingCount(), 0, '全部定时器已结清(一次性触发后即注销)')
60
+ await p100 // promise 形态确实落定过(then 链已在 advance 内跑完)
61
+ } finally { await env.cleanup() }
62
+ })
63
+
64
+ // ===== harness 面③:subagents 剧本 —— 一次性 run 可控结局 + continuable 句柄 + 记录器 =====
65
+ test('harness·subagents 面:一次性 run 由测试驱动结局,continuable/sendMessage/interrupt/listChildren 留痕', async () => {
66
+ var env = createMockCtx()
67
+ try {
68
+ // 一次性 run:result promise 由测试驱动
69
+ var run = await env.ctx.subagents.start('mock-provider', { label: 'worker:t1', prompt: [{ type: 'text', text: 'p' }] })
70
+ assert.equal(env.runs.length, 1, 'start 产出登记进 runs 剧本表')
71
+ assert.equal(run.req.label, 'worker:t1', 'req 原文留痕(label/prompt 可断言)')
72
+ var settled = null
73
+ run.result.then(function (r) { settled = r }, function () {})
74
+ await new Promise(function (r) { setImmediate(r) })
75
+ assert.equal(settled, null, '未 resolveWith 前 result 不落定(测试掌控节奏)')
76
+ // localAgent steer/followup 记录器(doIntervene 通道句柄)
77
+ run.localAgent.steer('高优指令')
78
+ run.localAgent.followup('补充消息')
79
+ assert.deepEqual(run.localAgent.steered, ['高优指令'])
80
+ assert.deepEqual(run.localAgent.followed, ['补充消息'])
81
+ run.resolveWith('交付文本')
82
+ var res = await run.result
83
+ assert.equal(res.output[0].text, '交付文本', 'outputText 可指定({ output:[{type:text}], stopReason } 形状)')
84
+ assert.equal(res.stopReason, 'completed')
85
+ // 失败结局:failWith 拒绝 result
86
+ var run2 = await env.ctx.subagents.start('mock-provider', { label: 'verifier:t2' })
87
+ run2.failWith('boom')
88
+ await assert.rejects(run2.result, /boom/)
89
+ // continuable 面:{ childId, messageId } 句柄
90
+ var c = await env.ctx.subagents.startContinuable({ provider: 'mock-provider', label: 'worker:t3', request: { prompt: [] } })
91
+ assert.ok(c.childId && c.messageId, 'startContinuable 返回 { childId, messageId }')
92
+ assert.equal(env.continuables[0].label, 'worker:t3', 'continuable 剧本登记 label/request')
93
+ // sendMessage / interrupt / listChildren 记录器
94
+ await env.ctx.subagents.sendMessage(env.root, c.childId, [{ type: 'text', text: '续跑指令' }])
95
+ assert.equal(env.sendMessages[0].childId, c.childId)
96
+ assert.equal(env.sendMessages[0].content[0].text, '续跑指令')
97
+ env.ctx.subagents.interrupt(c.childId, { kind: 'ancestor', agent: env.root })
98
+ assert.equal(env.interrupts[0].childId, c.childId)
99
+ assert.equal(env.interrupts[0].authority.kind, 'ancestor', 'interrupt authority 形状留痕')
100
+ env.children.push({ id: 'child-x', label: 'x' })
101
+ assert.deepEqual(await env.ctx.subagents.listChildren(env.sid), [{ id: 'child-x', label: 'x' }], 'listChildren 剧本可整组替换')
102
+ } finally { await env.cleanup() }
103
+ })
104
+
105
+ // ===== harness 面④:agentEvents —— emitStatus 重放 agent/status 事件 =====
106
+ test('harness·agentEvents 面:emitStatus 手动发射事件,payload 形状与退订语义正确', async () => {
107
+ var env = createMockCtx()
108
+ try {
109
+ var got = []
110
+ var off = env.ctx.on('agent/status', function (p) { got.push(p) })
111
+ env.emitStatus('child-9', 'running')
112
+ env.emitStatus('child-9', 'idle')
113
+ assert.deepEqual(got.map(function (p) { return p.status }), ['running', 'idle'])
114
+ assert.equal(got[0].agent.session.id, 'child-9', 'payload 形状 { agent: { session: { id } }, status }(与 host 事件契约一致)')
115
+ off()
116
+ env.emitStatus('child-9', 'idle')
117
+ assert.equal(got.length, 2, '退订后不再接收')
118
+ } finally { await env.cleanup() }
119
+ })
120
+
121
+ // ===== harness 面⑤:webServer 直调 + agents 剧本 + tools/systemPrompt/llm 记录器(需 apply 接线)=====
122
+ test('harness·webServer/agents 面:apply 后走真 readBody/handler 路径直调 RPC,工具与提示词留痕', async () => {
123
+ var env = createMockCtx()
124
+ try {
125
+ // agents 剧本:currentInitiator/roots 可按测试改写
126
+ assert.equal(env.ctx.agents.currentInitiator().id, env.sid, '缺省 actor=root sid')
127
+ env.actor = 'sub-agent-1'
128
+ assert.equal(env.ctx.agents.currentInitiator().id, 'sub-agent-1', 'actor 可改写模拟子代理身份')
129
+ env.actor = env.sid
130
+ assert.equal(env.ctx.agents.roots()[0].session.header.cwd, env.cwd, 'root.session.header.cwd 是 sessionCwd 来源')
131
+ // 真实 index.mjs 接线(薄壳:session→store→notify→dispatch→rpc)
132
+ plugin.apply(env.ctx)
133
+ // webServer register 捕获的路由:env.rpc 以真语义直调(POST body → readBody → handlers[method])
134
+ var r = await env.rpc('get-tasks', { sessionId: env.sid })
135
+ assert.equal(r.sessionId, env.sid)
136
+ assert.ok(Array.isArray(r.tasks) && r.tasks.length === 0, '新会话空板')
137
+ assert.equal(r.isRoot, true)
138
+ // tools.register 记录器:工具表按名可查、execute 可真跑
139
+ assert.ok(env.tools.get('task_create'), 'task_create 已注册')
140
+ assert.ok(env.tools.get('board_report'), 'board_report 已注册')
141
+ var listed = await env.tools.get('task_list').execute({})
142
+ assert.ok(Array.isArray(listed.tasks) && listed.tasks.length === 0, 'task_list 工具真跑返回空表')
143
+ // systemPrompt 记录器:Team 引导段在 apply 时注册
144
+ assert.ok(env.spSections.some(function (s) { return s.name === 'task-board:team-mode' }), 'systemPrompt.section 注册留痕')
145
+ // llm 记录器:list-models RPC 触发 listProviders
146
+ var lm = await env.rpc('list-models', {})
147
+ assert.equal(lm.ok, true)
148
+ assert.equal(env.llm.listProviders, 1, 'llm.listProviders 调用留痕')
149
+ } finally { await env.cleanup() }
150
+ })
151
+
152
+ // ===== ② 冒烟链路:建卡 → 虚拟时钟推进 → 派发发生 → 事件结算 → 断点续跑 =====
153
+ // 这一例证明 harness 可用:真实 index.mjs 全套接线在 mockCtx 上跑通核心派发闭环。
154
+ // 链路:create-task RPC → mutateLocked 写盘 → kickCycle(虚拟 50ms 去抖)→ poolCycle →
155
+ // pickDispatch → spawnOneShot → subagents.startContinuable(workerContinuable 缺省开)→
156
+ // agent/status running→idle 事件结算(空文本按失败重排,interrupt 留存)→
157
+ // 再推进去抖 → tryResumeWorker 命中续跑资格 → sendMessage 冷复活(不再 fresh spawn)。
158
+ test('冒烟链路:apply(mockCtx) → 建卡 → 时钟推进 → continuable 派发 → 事件结算 → 断点续跑', async () => {
159
+ var env = createMockCtx()
160
+ try {
161
+ plugin.apply(env.ctx)
162
+ // 建一张卡(pipeline=work:只做不验,避开 verifier 支线;显式 description 避开软提示)
163
+ var created = await env.rpc('create-task', { sessionId: env.sid, id: 'task-smoke-1', title: '冒烟任务', description: '无头冒烟链路用例', pipeline: 'work' })
164
+ assert.equal(created.ok, true)
165
+ assert.equal(created.task.status, 'pending', '非 Team 模式即建即 pending')
166
+ assert.ok(fs.existsSync(path.join(env.home, '.dsh', 'tasks-' + env.sid + '.json')), '看板文件落在临时 HOME')
167
+ // 虚拟时钟推进 50ms(kickCycle 去抖窗口)→ poolCycle 起跑;链尾真实 I/O 由 waitFor 收敛
168
+ await env.clock.advance(50)
169
+ // 核心断言:派发发生——subagents 剧本收到 continuable spawn
170
+ await env.waitFor(function () { return env.continuables.length >= 1 }, 'continuable spawn 发生')
171
+ var child = env.continuables[0]
172
+ assert.equal(child.label, 'worker:task-smoke-1', 'spawn label 形如 worker:<taskId>')
173
+ assert.ok(child.request.prompt[0].text.indexOf('冒烟任务') >= 0, '首条 prompt 携带任务标题')
174
+ assert.equal(env.runs.length, 0, 'Worker 走 continuable 面,未动用一次性 start')
175
+ // 卡面:占位 claim(spawn-pending)已换成真实 childId
176
+ await env.waitFor(function () {
177
+ var t = env.task('task-smoke-1')
178
+ return t && t.status === 'in-progress' && t.claimedBy === child.childId ? t : null
179
+ }, '任务落卡 in-progress 且 claimedBy=childId')
180
+ // 事件结算:continuable 无 run.result,agent/status 的 running→idle 是唯一结算信号
181
+ env.emitStatus(child.childId, 'running')
182
+ env.emitStatus(child.childId, 'idle')
183
+ // 无 v4 会话日志可读 → 空文本按失败结算:回 pending 重排(settleContinuable 既定语义),interrupt 留存子会话。
184
+ // waitFor 必须等整条结算链尾(settleWorker 落卡 + closeRunHistory 关账)——否则 cleanup 会
185
+ // rm 掉在飞写盘的临时目录(链尾不定长是真实 I/O,这正是 harness 提供 waitFor 的原因)。
186
+ await env.waitFor(function () {
187
+ var t = env.task('task-smoke-1')
188
+ return t && t.status === 'pending' && t.retryCount === 1 && t.runs && t.runs.length === 1 && t.runs[0].outcome === 'incomplete' ? t : null
189
+ }, '空文本失败重排 pending 且 run 关账 incomplete')
190
+ var t1 = env.task('task-smoke-1')
191
+ assert.ok(env.interrupts.some(function (i) { return i.childId === child.childId }), '结算收尾 interrupt 留存(不销毁子会话)')
192
+ assert.equal(t1.runs[0].continuable, true)
193
+ // 断点续跑:再推进一次去抖窗口 → 命中续跑资格 → sendMessage 冷复活原 child,不再 fresh spawn
194
+ await env.clock.advance(50)
195
+ await env.waitFor(function () { return env.sendMessages.length >= 1 }, '断点续跑 sendMessage 发出')
196
+ assert.equal(env.sendMessages[0].childId, child.childId, '续跑唤醒的是原子会话')
197
+ assert.ok(env.sendMessages[0].content[0].text.indexOf('断点续跑') >= 0, '续跑指令文案')
198
+ assert.equal(env.continuables.length, 1, '续跑命中 → 不再 spawn 新子会话')
199
+ // 续跑轮再次事件结算(仍空文本 → retryCount 2);runs 留档两条,第二条 resume:true 且关账 incomplete
200
+ env.emitStatus(child.childId, 'running')
201
+ env.emitStatus(child.childId, 'idle')
202
+ await env.waitFor(function () {
203
+ var t = env.task('task-smoke-1')
204
+ return t && t.status === 'pending' && t.retryCount === 2 && t.runs && t.runs.length === 2 && t.runs[1].outcome === 'incomplete' ? t : null
205
+ }, '续跑轮失败重排且关账')
206
+ var t2 = env.task('task-smoke-1')
207
+ assert.equal(t2.runs[1].resume, true, '第二条标 resume:true')
208
+ } finally { await env.cleanup() }
209
+ })
@@ -0,0 +1,327 @@
1
+ // dsh-agent-board — 无头宿主基建(test/helpers/mock-ctx.mjs)
2
+ // 在**无 DSH 环境**下托住真实 index.mjs 的 apply(ctx):全套假 cordis 服务 + 真 fs 临时 HOME +
3
+ // 虚拟时钟 + 剧本化 subagents + webServer 路由捕获 + systemPrompt/llm 记录器。
4
+ //
5
+ // 设计口径(与宿主真实契约逐一对齐,来源:lib/dispatch.mjs / lib/notify.mjs / lib/store.mjs / lib/rpc.mjs):
6
+ // · timer.timeout(ms) → Promise(kickCycle 50ms / 歧义去抖 25s / 回执聚合 45s / withTimeout 硬超时)
7
+ // · timer.timeout(cb, ms) → dispose 函数(软超时臂,armTimeouts 回调式用法)
8
+ // · timer.interval(fn, ms) → dispose 函数(15s 心跳)
9
+ // · subagents.start(provider, req) → 一次性 run(result promise 由测试驱动落定/拒绝)
10
+ // · subagents.startContinuable({...}) → { childId, messageId }(持久子会话,turn 结算走 emitStatus)
11
+ // · subagents.sendMessage / interrupt / listChildren → 记录器 + 可剧本化
12
+ // · agents.currentInitiator()/roots()/list()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root)
13
+ // · tools.register / webServer.register → 捕获工具表与 RPC 路由(env.rpc 走真 readBody/handler 路径)
14
+ // · ctx.get('systemPrompt'|'llm') → 记录器(注册了什么段/查了什么模型都留痕)
15
+ // · ctx.effect / ctx.on('agent/status') → 真实执行 + disposer 收口 / 事件捕获与重放
16
+ //
17
+ // 看板文件走真路径:store 层用 node:fs 直读写 boardHome()=<$HOME>/.dsh——本 harness 把
18
+ // process.env.HOME 指到 os.tmpdir 下的独立临时目录(boardDirName() 每次现读 env,进程内安全),
19
+ // cleanup() 恢复原 env 并递归删除临时目录。每个用例一个独立 mockCtx + 独立临时目录,互不污染。
20
+ // 注意:插件自身从不 mkdir .dsh(生产上宿主保证 ~/.dsh 已存在),故 harness 建目录时预建
21
+ // <home>/.dsh 以镜像这一前置条件——否则 wt() 的 tmp 文件写入会 ENOENT。
22
+ //
23
+ // 虚拟时钟只接管 ctx.timer 注册的所有定时器;Date.now() 仍是真实墙钟(看板时间戳语义不变)。
24
+ // advance(ms) 按到期顺序同步触发回调/落定 promise,每轮之间让出宏任务使异步链(poolCycle→spawn→
25
+ // 落盘)起跑;链尾用 waitFor 轮询收敛(真实 I/O 不定长,这是有意设计而非竞态)。
26
+
27
+ import fs from 'node:fs'
28
+ import os from 'node:os'
29
+ import path from 'node:path'
30
+ import { Readable } from 'node:stream'
31
+
32
+ /**
33
+ * @typedef {Object} MockEnv
34
+ * @property {Object} ctx 假 cordis ctx——直接传给插件 apply(ctx)
35
+ * @property {string} sid 本会话(root agent)id
36
+ * @property {string} home 临时 HOME 目录(看板文件在 <home>/.dsh/tasks-<sid>.json)
37
+ * @property {Object} clock 虚拟时钟:advance(ms) 推进并触发到期定时器;tick() 手动触发一次心跳 interval;now() 当前虚拟毫秒
38
+ * @property {Map} tools 已注册工具表(task_create/board_report/board_verdict/task_arbitrate/...),调 .execute(args)
39
+ * @property {(method:string, args?:Object)=>Promise<any>} rpc 以真 RPC 语义(POST body → readBody → handler)调捕获的路由
40
+ * @property {Array} runs start() 产出的一次性 run:{ id, req, result, disposed, localAgent, resolveWith(text,stopReason?), failWith(err) }
41
+ * @property {Array} continuables startContinuable 产出:{ childId, messageId, label, request }
42
+ * @property {Array} sendMessages sendMessage 记录:{ parent, childId, content }
43
+ * @property {Array} interrupts interrupt 记录:{ childId, authority }
44
+ * @property {Array} children listChildren 剧本(可整组替换返回值)
45
+ * @property {(childId:string, status:'running'|'idle')=>void} emitStatus 重放 agent/status 事件(continuable 结算触发器)
46
+ * @property {Array} sent root.followup 捕获的消息(makeMsg 形态:content[0].text 取文本)
47
+ * @property {Object} root 假 root agent(followup 记录器;刻意不带 whenIdle → 回执立即投递分支)
48
+ * @property {Object} spSections systemPrompt 注册段记录器
49
+ * @property {Object} llm llm 记录器(listProviders/listModels 调用留痕)
50
+ * @property {string} actor currentInitiator 返回值(缺省=root sid;测试可改写模拟子代理身份)
51
+ * @property {()=>Object} board 读当前看板文件 JSON(不存在返回 null)
52
+ * @property {(id:string)=>Object|undefined} task 看板里按 id 取任务
53
+ * @property {(fn:Function, label?:string, timeoutMs?:number)=>Promise<any>} waitFor 真实时间轮询直到条件为真(默认 5s 上限)
54
+ * @property {(label:string)=>Object|undefined} lastRun 按 label 前缀找最近一次一次性 run(label 形如 'worker:task-x')
55
+ * @property {(fn:(d:Object)=>void)=>void} patchBoard 直接改写看板文件(构造孤儿/脏数据场景用)
56
+ * @property {()=>Promise<void>} cleanup 收尾:跑全部 disposer、恢复 HOME env、递归删临时目录
57
+ */
58
+
59
+ /**
60
+ * 创建一套无头宿主环境。
61
+ * @param {Object} [opts]
62
+ * @param {string} [opts.sid] 会话 id(缺省自动生成,形如 session-mock-xxxx-xx——满足 isFullSessionId 结构)
63
+ * @param {string} [opts.cwd] root agent 的工作区路径(缺省=临时 home;sessionCwd/ownerCwd 来源)
64
+ * @returns {MockEnv}
65
+ */
66
+ export function createMockCtx(opts) {
67
+ opts = opts || {}
68
+ var sid = opts.sid || ('session-mock-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36))
69
+ var home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-board-test-'))
70
+ // 镜像宿主前置条件:生产上 ~/.dsh 由宿主保证存在(插件自身从不 mkdir)——临时 HOME 里预建,
71
+ // 否则 store.wt() 写 <home>/.dsh/tasks-<sid>.json.tmp 会 ENOENT。
72
+ fs.mkdirSync(path.join(home, '.dsh'), { recursive: true })
73
+ var cwd = opts.cwd || home
74
+
75
+ // ===== 临时 HOME 接管(boardDirName 每次现读 env → 看板文件落在这里,与真实家目录隔离)=====
76
+ var prevHome = process.env.HOME
77
+ var hadHome = Object.prototype.hasOwnProperty.call(process.env, 'HOME')
78
+ process.env.HOME = home
79
+
80
+ // ===== 虚拟时钟 =====
81
+ var now = 0
82
+ var seq = 0
83
+ var timers = [] // { id, at, every(0=once), cb, cancelled }
84
+ function schedule(at, every, cb) { var t = { id: ++seq, at: at, every: every || 0, cb: cb, cancelled: false }; timers.push(t); return t }
85
+ function flushMacro(turns) { // 让 promise 连续体与 fs 回调起跑(不定长 I/O 的链尾由 waitFor 收敛)
86
+ var p = Promise.resolve()
87
+ for (var i = 0; i < (turns || 6); i++) p = p.then(function () { return new Promise(function (r) { setImmediate(r) }) })
88
+ return p
89
+ }
90
+ var clock = {
91
+ now: function () { return now },
92
+ pendingCount: function () { return timers.filter(function (t) { return !t.cancelled }).length },
93
+ // 推进虚拟时间:按到期顺序触发,interval 自动续挂;每触发一个让出一次宏任务
94
+ advance: async function (ms) {
95
+ var target = now + ms
96
+ for (;;) {
97
+ var next = null
98
+ for (var i = 0; i < timers.length; i++) {
99
+ var t = timers[i]
100
+ if (t.cancelled || t.at > target) continue
101
+ if (!next || t.at < next.at || (t.at === next.at && t.id < next.id)) next = t
102
+ }
103
+ if (!next) break
104
+ now = next.at
105
+ if (next.every > 0) next.at = now + next.every
106
+ else next.cancelled = true
107
+ try { next.cb() } catch (e) { console.error('[mock-ctx] 定时器回调异常:', String(e)) }
108
+ await flushMacro(3)
109
+ }
110
+ now = target
111
+ await flushMacro(6)
112
+ },
113
+ // 手动触发一次心跳(interval 回调),不动虚拟时间、不改 interval 的既有排程
114
+ tick: async function () {
115
+ var cbs = []
116
+ for (var i = 0; i < timers.length; i++) { var t = timers[i]; if (!t.cancelled && t.every > 0) cbs.push(t.cb) }
117
+ for (var j = 0; j < cbs.length; j++) { try { cbs[j]() } catch (e) { console.error('[mock-ctx] 心跳回调异常:', String(e)) } }
118
+ await flushMacro(6)
119
+ },
120
+ }
121
+ var timer = {
122
+ // 双形态:timeout(ms)→Promise(去抖/聚合/硬超时 race);timeout(cb, ms)→dispose(软超时臂)
123
+ timeout: function (a, b) {
124
+ if (typeof a === 'function') {
125
+ var t1 = schedule(now + (typeof b === 'number' ? b : 0), 0, a)
126
+ return function () { t1.cancelled = true }
127
+ }
128
+ var ms = typeof a === 'number' ? a : 0
129
+ return new Promise(function (resolve) { schedule(now + ms, 0, function () { resolve() }) })
130
+ },
131
+ interval: function (fn, ms) {
132
+ var t2 = schedule(now + (typeof ms === 'number' ? ms : 0), typeof ms === 'number' ? ms : 0, fn)
133
+ return function () { t2.cancelled = true }
134
+ },
135
+ }
136
+
137
+ // ===== 剧本化 subagents =====
138
+ var runs = []
139
+ var continuables = []
140
+ var sendMessages = []
141
+ var interrupts = []
142
+ var children = []
143
+ var runSeq = 0
144
+ var childSeq = 0
145
+ function makeRun(provider, req) {
146
+ var settle = {}
147
+ var result = new Promise(function (res, rej) { settle.res = res; settle.rej = rej })
148
+ var run = {
149
+ id: 'run-' + (++runSeq),
150
+ provider: provider,
151
+ req: req, // { label, prompt, parent, signal, agentOptions? }——label 形如 'worker:task-x' / 'verifier:task-x'
152
+ result: result,
153
+ disposed: false,
154
+ localAgent: { // doIntervene 通道句柄:steer/followup 记录器
155
+ steered: [],
156
+ followed: [],
157
+ steer: function (m) { this.steered.push(m) },
158
+ followup: function (m) { this.followed.push(m) },
159
+ },
160
+ dispose: async function () { run.disposed = true },
161
+ // 测试驱动结局:resolveWith('文本') = 正常完成(outputText 可指定);failWith(e) = run 失败
162
+ resolveWith: function (text, stopReason) { settle.res({ output: [{ type: 'text', text: text || '' }], stopReason: stopReason || 'completed' }) },
163
+ failWith: function (err) { settle.rej(err instanceof Error ? err : new Error(String(err))) },
164
+ }
165
+ runs.push(run)
166
+ return run
167
+ }
168
+ var subagents = {
169
+ list: function () { return ['mock-provider'] },
170
+ getProvider: function () { return { inheritsParentContext: false } },
171
+ start: async function (provider, req) { return makeRun(provider, req) },
172
+ startContinuable: async function (o) {
173
+ var c = { childId: 'child-' + (++childSeq), messageId: 'm' + childSeq, label: o && o.label, request: o && o.request }
174
+ continuables.push(c)
175
+ return c
176
+ },
177
+ sendMessage: async function (parent, childId, content) { sendMessages.push({ parent: parent, childId: childId, content: content }); return { ok: true } },
178
+ interrupt: function (childId, authority) { interrupts.push({ childId: childId, authority: authority }) },
179
+ listChildren: async function () { return children },
180
+ }
181
+
182
+ // ===== agents(缺省单 root;actor 可改写模拟子代理/其他会话身份)=====
183
+ var sent = []
184
+ var root = {
185
+ id: sid,
186
+ session: { id: sid, header: { cwd: cwd } },
187
+ followup: function (m) { sent.push(m) },
188
+ // whenIdle 刻意不实现:flushReceipts 走「无空闲门控 → 立即投递」分支(45s 窗口到点即投,可断言)
189
+ }
190
+ var env // 前向引用(currentInitiator 读 env.actor)
191
+ var agents = {
192
+ currentInitiator: function () { return { id: env.actor } },
193
+ roots: function () { return [root] },
194
+ list: function () { return [root] },
195
+ isOwnedBy: function () { return false },
196
+ }
197
+
198
+ // ===== tools / webServer / systemPrompt / llm / effect / on =====
199
+ var tools = new Map()
200
+ var routes = []
201
+ var spSections = []
202
+ var llmCalls = { listProviders: 0, listModels: [] }
203
+ var disposers = []
204
+ var listeners = {} // event -> Set<fn>
205
+
206
+ var ctx = {
207
+ fs: {}, // store 层走 node:fs 直读写(家目录绝对路径),fs 服务只是占位——保留键位对齐真实契约
208
+ timer: timer,
209
+ subagents: subagents,
210
+ agents: agents,
211
+ tools: { register: function (t) { tools.set(t.name, t) } },
212
+ webServer: {
213
+ register: function (r) {
214
+ routes.push(r)
215
+ return function () { var i = routes.indexOf(r); if (i >= 0) routes.splice(i, 1) }
216
+ },
217
+ },
218
+ get: function (name) {
219
+ if (name === 'systemPrompt') {
220
+ return {
221
+ sections: spSections,
222
+ section: function (spec) { spSections.push(spec); return function () { var i = spSections.indexOf(spec); if (i >= 0) spSections.splice(i, 1) } },
223
+ }
224
+ }
225
+ if (name === 'llm') {
226
+ return {
227
+ listProviders: function () { llmCalls.listProviders++; return [] },
228
+ listModels: async function (p) { llmCalls.listModels.push(p); return [] },
229
+ }
230
+ }
231
+ return undefined
232
+ },
233
+ effect: function (fn) {
234
+ var d
235
+ if (typeof fn === 'function') d = fn()
236
+ if (typeof d === 'function') disposers.push(d)
237
+ return function () {}
238
+ },
239
+ on: function (event, fn) {
240
+ if (!listeners[event]) listeners[event] = new Set()
241
+ listeners[event].add(fn)
242
+ return function () { listeners[event].delete(fn) }
243
+ },
244
+ }
245
+
246
+ env = {
247
+ ctx: ctx,
248
+ sid: sid,
249
+ home: home,
250
+ cwd: cwd,
251
+ clock: clock,
252
+ tools: tools,
253
+ runs: runs,
254
+ continuables: continuables,
255
+ sendMessages: sendMessages,
256
+ interrupts: interrupts,
257
+ children: children,
258
+ sent: sent,
259
+ root: root,
260
+ spSections: spSections,
261
+ llm: llmCalls,
262
+ actor: sid,
263
+
264
+ // 以真 RPC 语义调用捕获的 webServer 路由(POST /dsh-agent-board → readBody → handlers[method])
265
+ rpc: async function (method, args) {
266
+ var route = routes[0]
267
+ if (!route) throw new Error('webServer 路由未注册(apply 未跑?)')
268
+ var req = Readable.from([Buffer.from(JSON.stringify({ method: method, args: args || {} }), 'utf8')])
269
+ req.method = 'POST'
270
+ var res = {
271
+ statusCode: 0, body: '',
272
+ setHeader: function () {},
273
+ writeHead: function (c) { res.statusCode = c },
274
+ end: function (s) { res.body = s },
275
+ }
276
+ await route.handler(req, res)
277
+ return JSON.parse(res.body)
278
+ },
279
+
280
+ // 重放 host 事件 agent/status(continuable Worker 的 turn 结算触发器:先 running 后 idle)
281
+ emitStatus: function (childId, status) {
282
+ var set = listeners['agent/status']
283
+ if (!set) return
284
+ set.forEach(function (fn) { fn({ agent: { session: { id: childId } }, status: status }) })
285
+ },
286
+
287
+ board: function () {
288
+ try { return JSON.parse(fs.readFileSync(path.join(home, '.dsh', 'tasks-' + sid + '.json'), 'utf8')) } catch (_) { return null }
289
+ },
290
+ task: function (id) {
291
+ var d = env.board()
292
+ return d && d.tasks.find(function (t) { return t.id === id })
293
+ },
294
+ patchBoard: function (fn) {
295
+ var p = path.join(home, '.dsh', 'tasks-' + sid + '.json')
296
+ var d = JSON.parse(fs.readFileSync(p, 'utf8'))
297
+ fn(d)
298
+ fs.writeFileSync(p, JSON.stringify(d), 'utf8')
299
+ },
300
+ lastRun: function (labelPrefix) {
301
+ for (var i = runs.length - 1; i >= 0; i--) { if (runs[i].req && String(runs[i].req.label).indexOf(labelPrefix) === 0) return runs[i] }
302
+ return undefined
303
+ },
304
+
305
+ // 真实时间轮询收敛:异步链(读盘→持锁→写盘→spawn→落卡)的终点不定长,按条件等到为止
306
+ waitFor: async function (fn, label, timeoutMs) {
307
+ var t0 = Date.now()
308
+ var cap = timeoutMs || 5000
309
+ for (;;) {
310
+ var v
311
+ try { v = await fn() } catch (_) { v = null }
312
+ if (v) return v
313
+ if (Date.now() - t0 > cap) throw new Error('waitFor 超时(' + cap + 'ms): ' + (label || '(未命名条件)'))
314
+ await new Promise(function (r) { setTimeout(r, 5) })
315
+ }
316
+ },
317
+
318
+ cleanup: async function () {
319
+ for (var i = 0; i < disposers.length; i++) { try { disposers[i]() } catch (_) {} }
320
+ for (var j = 0; j < timers.length; j++) timers[j].cancelled = true
321
+ if (hadHome) process.env.HOME = prevHome
322
+ else delete process.env.HOME
323
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch (_) {}
324
+ },
325
+ }
326
+ return env
327
+ }