@xdxer/dingtalk-agent 0.1.1 → 0.1.2

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 (99) hide show
  1. package/README.md +81 -80
  2. package/dist/bin/dingtalk-agent.js +685 -0
  3. package/dist/bin/dingtalk-agent.js.map +1 -0
  4. package/dist/src/actions.js +562 -0
  5. package/dist/src/actions.js.map +1 -0
  6. package/dist/src/boot.js +70 -0
  7. package/dist/src/boot.js.map +1 -0
  8. package/dist/src/bootstrap.js +144 -0
  9. package/dist/src/bootstrap.js.map +1 -0
  10. package/dist/src/config.js +86 -0
  11. package/dist/src/config.js.map +1 -0
  12. package/dist/src/driver.js +45 -0
  13. package/dist/src/driver.js.map +1 -0
  14. package/{src → dist/src}/duty.js +42 -44
  15. package/dist/src/duty.js.map +1 -0
  16. package/dist/src/dws.js +270 -0
  17. package/dist/src/dws.js.map +1 -0
  18. package/dist/src/events.js +233 -0
  19. package/dist/src/events.js.map +1 -0
  20. package/dist/src/fields.js +132 -0
  21. package/dist/src/fields.js.map +1 -0
  22. package/dist/src/init.js +41 -0
  23. package/dist/src/init.js.map +1 -0
  24. package/dist/src/kb.js +240 -0
  25. package/dist/src/kb.js.map +1 -0
  26. package/dist/src/package-root.js +17 -0
  27. package/dist/src/package-root.js.map +1 -0
  28. package/dist/src/runs.js +79 -0
  29. package/dist/src/runs.js.map +1 -0
  30. package/dist/src/sessions.js +668 -0
  31. package/dist/src/sessions.js.map +1 -0
  32. package/dist/src/skill-manager.js +273 -0
  33. package/dist/src/skill-manager.js.map +1 -0
  34. package/dist/src/skills.js +200 -0
  35. package/dist/src/skills.js.map +1 -0
  36. package/dist/src/types.js +2 -0
  37. package/dist/src/types.js.map +1 -0
  38. package/dist/src/waits.js +945 -0
  39. package/dist/src/waits.js.map +1 -0
  40. package/dist/src/workspace.js +173 -0
  41. package/dist/src/workspace.js.map +1 -0
  42. package/docs/ARCHITECTURE.md +207 -0
  43. package/docs/MINIMAL-WORKSPACE-V1.md +172 -0
  44. package/docs/OPEN-SOURCE-REFERENCES.md +107 -0
  45. package/docs/SELF-TEST.md +252 -0
  46. package/docs/architecture/dingtalk-agent-blueprint.png +0 -0
  47. package/docs/architecture/dingtalk-agent-blueprint.svg +144 -0
  48. package/docs/architecture/durable-async-agent-runtime.png +0 -0
  49. package/docs/architecture/durable-async-agent-runtime.svg +234 -0
  50. package/docs//345/206/205/347/275/221/345/256/236/347/233/270.md +77 -0
  51. package/evals/baselines/2026-07-14/behavior-summary.json +28 -0
  52. package/evals/baselines/2026-07-14/contract-summary.json +18 -0
  53. package/evals/baselines/2026-07-14/live-canary-summary.json +25 -0
  54. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/SKILL.md +72 -0
  55. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/references/action-contract.md +31 -0
  56. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/references/event-to-behavior.md +22 -0
  57. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/references/memory-and-evolution.md +25 -0
  58. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/references/runtime-modes.md +34 -0
  59. package/evals/baselines/2026-07-15/task-lifecycle-summary.json +50 -0
  60. package/evals/evals.json +293 -0
  61. package/evals/fixtures/dm-ambiguous-send.json +4 -0
  62. package/evals/fixtures/dm-blocked.json +4 -0
  63. package/evals/fixtures/dm-clear.json +4 -0
  64. package/evals/fixtures/dm-discussion.json +4 -0
  65. package/evals/fixtures/dm-doc-write-no-tool.json +4 -0
  66. package/evals/fixtures/dm-long-task-ack.json +4 -0
  67. package/evals/fixtures/dm-nonblocking-gap.json +4 -0
  68. package/evals/fixtures/dm-structured-task.json +4 -0
  69. package/evals/fixtures/group.json +10 -0
  70. package/evals/fixtures/mentioned.json +3 -0
  71. package/evals/run-contract-evals.mjs +1031 -0
  72. package/evals/run-shadow-evals.mjs +267 -0
  73. package/evals/runners/README.md +66 -0
  74. package/evals/runners/claude-shadow.mjs +533 -0
  75. package/evals/schemas/action-request.schema.json +77 -0
  76. package/evals/shadow-evals.json +133 -0
  77. package/package.json +28 -6
  78. package/skills/AGENTS.md +21 -3
  79. package/skills/dingtalk-basic-behavior/SKILL.md +86 -0
  80. package/skills/dingtalk-basic-behavior/assets/task-checkpoint.md +37 -0
  81. package/skills/dingtalk-basic-behavior/references/action-contract.md +31 -0
  82. package/skills/dingtalk-basic-behavior/references/event-to-behavior.md +24 -0
  83. package/skills/dingtalk-basic-behavior/references/memory-and-evolution.md +27 -0
  84. package/skills/dingtalk-basic-behavior/references/runtime-modes.md +34 -0
  85. package/skills/dingtalk-basic-behavior/references/task-lifecycle.md +108 -0
  86. package/skills//345/237/272/347/241/200/350/241/214/344/270/272.md +44 -0
  87. package/skills//345/277/203/350/267/263.md +11 -0
  88. package/skills//346/266/210/346/201/257.md +14 -14
  89. package/skills//350/257/204/346/265/213.md +14 -1
  90. package/skills//351/222/211/351/222/211.md +3 -2
  91. package/templates/behaviors/basic.json +68 -0
  92. package/templates/fields/default/field.json +25 -0
  93. package/bin/dingtalk-agent.js +0 -289
  94. package/src/boot.js +0 -65
  95. package/src/config.js +0 -42
  96. package/src/dws.js +0 -192
  97. package/src/init.js +0 -84
  98. package/src/kb.js +0 -221
  99. package/src/runs.js +0 -77
@@ -0,0 +1,1031 @@
1
+ #!/usr/bin/env node
2
+
3
+ // 纯本地合同评测:不调用真实 DWS 写能力。
4
+ import {
5
+ mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, chmodSync,
6
+ readdirSync, cpSync, existsSync, lstatSync, readlinkSync,
7
+ } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join, dirname } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { spawnSync } from 'node:child_process'
12
+ import { init } from '../dist/src/init.js'
13
+ import * as config from '../dist/src/config.js'
14
+ import { ackDispatch, listPendingDispatches, prepareSession, readJson } from '../dist/src/sessions.js'
15
+ import { previewAction, executeAction } from '../dist/src/actions.js'
16
+ import { heartbeatEvent } from '../dist/src/driver.js'
17
+ import { digest, normalizeEvent } from '../dist/src/events.js'
18
+ import {
19
+ cancelWaitForEvent, claimWait, readWaitForEvent, waitRoot,
20
+ } from '../dist/src/waits.js'
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url))
23
+ const ROOT = dirname(HERE)
24
+ // 合同结果只拥有自己的子目录,不能清掉同级 Claude/live 评测证据。
25
+ const RESULTS = join(HERE, 'results', 'contract')
26
+ const EVALS = readJson(join(HERE, 'evals.json')).evals
27
+ const CLI = join(ROOT, 'dist', 'bin', 'dingtalk-agent.js')
28
+ const WORKSPACES = []
29
+ process.on('exit', () => {
30
+ for (const root of WORKSPACES) rmSync(root, { recursive: true, force: true })
31
+ })
32
+
33
+ rmSync(RESULTS, { recursive: true, force: true })
34
+ mkdirSync(RESULTS, { recursive: true })
35
+
36
+ const cases = [
37
+ evalMentioned, evalAmbientGroup, evalDuplicate, evalTamper, evalInboxRecovery,
38
+ evalDwsStringData, evalSessionScope, evalSkillSnapshot, evalSingleEgress,
39
+ evalHeartbeatOccurrences, evalAskWaitResume, evalWaitScopeIsolation,
40
+ evalWaitConflict, evalWaitCancelFence, evalWaitReceiptRepair,
41
+ evalClaimSupersededByCancel, evalNegativeWaitRouting,
42
+ evalWorkspaceAuthority, evalSessionSkillBinding,
43
+ evalGlobalSkillInstall, evalBootstrapLocal, evalBootstrapRemote, evalOptionalInit,
44
+ evalManagedSkillLifecycle, evalRemoteStorageTypeGate, evalDirectActGate,
45
+ ]
46
+ const benchmarkRuns = []
47
+ for (let i = 0; i < cases.length; i++) {
48
+ const started = Date.now()
49
+ const result = cases[i]()
50
+ const expectations = EVALS[i].expectations.map((text, n) => ({
51
+ text,
52
+ passed: Boolean(result.checks[n]?.passed),
53
+ evidence: result.checks[n]?.evidence || '没有证据',
54
+ }))
55
+ const passed = expectations.filter((x) => x.passed).length
56
+ const runDir = join(RESULTS, `eval-${i + 1}`, 'with_skill')
57
+ mkdirSync(join(runDir, 'outputs'), { recursive: true })
58
+ writeJson(join(runDir, 'eval_metadata.json'), {
59
+ eval_id: i + 1,
60
+ prompt: EVALS[i].prompt,
61
+ expected_output: EVALS[i].expected_output,
62
+ })
63
+ writeJson(join(runDir, 'outputs', 'result.json'), result.output)
64
+ writeFileSync(join(runDir, 'outputs', 'summary.md'),
65
+ `# Eval ${i + 1}\n\n${EVALS[i].expected_output}\n\n` +
66
+ expectations.map((x) => `- ${x.passed ? '✅' : '❌'} ${x.text}: ${x.evidence}`).join('\n') + '\n')
67
+ writeJson(join(runDir, 'grading.json'), {
68
+ expectations,
69
+ summary: { passed, failed: expectations.length - passed,
70
+ total: expectations.length, pass_rate: passed / expectations.length },
71
+ execution_metrics: { tool_calls: { cli: result.toolCalls }, total_tool_calls: result.toolCalls,
72
+ total_steps: result.steps, errors_encountered: expectations.length - passed,
73
+ output_chars: JSON.stringify(result.output).length, transcript_chars: 0 },
74
+ timing: { executor_duration_seconds: (Date.now() - started) / 1000,
75
+ grader_duration_seconds: 0, total_duration_seconds: (Date.now() - started) / 1000 },
76
+ })
77
+ benchmarkRuns.push({
78
+ eval_id: i + 1,
79
+ eval_name: `Contract ${i + 1}`,
80
+ configuration: 'with_skill',
81
+ run_number: 1,
82
+ result: { pass_rate: passed / expectations.length, passed,
83
+ failed: expectations.length - passed, total: expectations.length,
84
+ time_seconds: (Date.now() - started) / 1000, tokens: 0,
85
+ tool_calls: result.toolCalls, errors: expectations.length - passed },
86
+ expectations,
87
+ notes: ['Deterministic runtime contract; no live DingTalk write was executed.'],
88
+ })
89
+ }
90
+
91
+ const mean = benchmarkRuns.reduce((sum, x) => sum + x.result.pass_rate, 0) / benchmarkRuns.length
92
+ writeJson(join(RESULTS, 'benchmark.json'), {
93
+ metadata: { skill_name: '基础行为', skill_path: ROOT,
94
+ executor_model: 'deterministic-cli', analyzer_model: 'deterministic-assertions',
95
+ timestamp: new Date().toISOString(), evals_run: EVALS.map((x) => x.id), runs_per_configuration: 1 },
96
+ runs: benchmarkRuns,
97
+ run_summary: {
98
+ with_skill: {
99
+ pass_rate: { mean, stddev: 0, min: mean, max: mean },
100
+ time_seconds: { mean: 0, stddev: 0, min: 0, max: 0 },
101
+ tokens: { mean: 0, stddev: 0, min: 0, max: 0 },
102
+ },
103
+ delta: { pass_rate: 'n/a', time_seconds: 'n/a', tokens: 'n/a' },
104
+ },
105
+ notes: ['这是可机器判定的运行时合同评测,不做无 Skill 的主观模型对照。',
106
+ '全部案例禁止真实 DWS 写操作。'],
107
+ })
108
+
109
+ if (process.argv.includes('--smoke')) {
110
+ const pass = mean === 1
111
+ const mark = pass ? 'PASS' : 'FAIL'
112
+ console.log(`${mark} 全局 Skill 可在无 Workspace 时安全安装`)
113
+ console.log(`${mark} 本地/钉钉文档 Storage 可在无 init 时水合`)
114
+ console.log(`${mark} init 可选、幂等且不污染项目级 Agent 文件`)
115
+ console.log(`${mark} Prepared Run 的 Session/Wait/目标/幂等闸门通过`)
116
+ if (!pass) process.exitCode = 1
117
+ } else {
118
+ console.log(JSON.stringify({ results: RESULTS, passRate: mean, evals: benchmarkRuns.length }, null, 2))
119
+ }
120
+
121
+ function evalMentioned() {
122
+ const { root, cfg } = workspace()
123
+ const event = fixture('mentioned.json')
124
+ const dispatch = prepareSession(root, cfg, event)
125
+ const target = readJson(join(dispatch.cwd, 'context', 'reply-target.json'))
126
+ return {
127
+ output: { dispatch, target }, toolCalls: 1, steps: 3,
128
+ checks: [
129
+ check(dispatch.eventKind === 'im.message.mentioned', `eventKind=${dispatch.eventKind}`),
130
+ check(sameSet(dispatch.allowedActions, ['ack', 'reply', 'ask', 'silence']),
131
+ `allowed=${dispatch.allowedActions.join(',')}`),
132
+ check(target.conversationId === 'cid-project-a' && target.messageId === 'mid-mentioned-001' &&
133
+ target.senderOpenDingTalkId === '$:LWCP_v1:$alice', JSON.stringify(target)),
134
+ ],
135
+ }
136
+ }
137
+
138
+ function evalAmbientGroup() {
139
+ const { root, cfg } = workspace()
140
+ const dispatch = prepareSession(root, cfg, fixture('group.json'))
141
+ let blocked = ''
142
+ try { previewAction(dispatch.cwd, 'reply', { text: '不应该发送' }) }
143
+ catch (e) { blocked = e.message }
144
+ const receipt = executeAction(dispatch.cwd, 'silence', { reason: 'unmentioned' })
145
+ return {
146
+ output: { dispatch, blocked, receipt }, toolCalls: 3, steps: 4,
147
+ checks: [
148
+ check(sameSet(dispatch.allowedActions, ['silence']), `allowed=${dispatch.allowedActions.join(',')}`),
149
+ check(blocked.includes('不允许 reply'), blocked),
150
+ check(receipt.kind === 'silence' && receipt.verified === true, JSON.stringify(receipt)),
151
+ ],
152
+ }
153
+ }
154
+
155
+ function evalDuplicate() {
156
+ const { root, cfg } = workspace()
157
+ const event = fixture('mentioned.json')
158
+ const first = prepareSession(root, cfg, event)
159
+ const second = prepareSession(root, cfg, event)
160
+ const pending = listPendingDispatches(root, cfg)
161
+ const ack = ackDispatch(root, cfg, first.eventId, first.runId, 'eval-controller')
162
+ const third = prepareSession(root, cfg, event)
163
+ return {
164
+ output: { first, second, pending, ack, third }, toolCalls: 5, steps: 5,
165
+ checks: [
166
+ check(first.sessionId === second.sessionId && first.runId === second.runId &&
167
+ pending.length === 1 && pending[0].runId === first.runId,
168
+ `${first.sessionId}/${first.runId}; pending=${pending.length}`),
169
+ check(second.duplicate === true && second.launch === true,
170
+ `duplicate=${second.duplicate}, launch=${second.launch}`),
171
+ check(ack.duplicate === false && third.duplicate === true && third.launch === false,
172
+ `ack=${ack.duplicate}, duplicate=${third.duplicate}, launch=${third.launch}`),
173
+ ],
174
+ }
175
+ }
176
+
177
+ function evalTamper() {
178
+ const { root, cfg } = workspace()
179
+ const dispatch = prepareSession(root, cfg, fixture('mentioned.json'))
180
+ const override = spawnSync(process.execPath, [CLI, 'act', 'reply', '--text', 'x',
181
+ '--conversation-id', 'evil'], { cwd: dispatch.cwd, encoding: 'utf8' })
182
+
183
+ const fake = join(root, 'fake-run')
184
+ mkdirSync(fake)
185
+ writeFileSync(join(fake, 'run.json'), readFileSync(join(dispatch.cwd, 'run.json')))
186
+ let forged = ''
187
+ try { previewAction(fake, 'silence') } catch (e) { forged = e.message }
188
+
189
+ const targetPath = join(dispatch.cwd, 'context', 'reply-target.json')
190
+ writeJson(targetPath, { conversationId: 'evil', messageId: 'evil', senderOpenDingTalkId: 'evil' })
191
+ let drift = ''
192
+ try { previewAction(dispatch.cwd, 'silence') } catch (e) { drift = e.message }
193
+ return {
194
+ output: { overrideStatus: override.status, overrideError: override.stderr.trim(), forged, drift },
195
+ toolCalls: 4, steps: 5,
196
+ checks: [
197
+ check(override.status !== 0 && override.stderr.includes('Unknown option'), override.stderr.trim()),
198
+ check(forged.includes('身份不一致'), forged),
199
+ check(drift.includes('投影被修改'), drift),
200
+ ],
201
+ }
202
+ }
203
+
204
+ function evalInboxRecovery() {
205
+ const { root, cfg } = workspace()
206
+ const event = fixture('mentioned.json')
207
+ const fieldPath = join(root, 'fields', 'default', 'field.json')
208
+ const field = readJson(fieldPath)
209
+ field.behavior.packs = ['behaviors/missing.json']
210
+ writeJson(fieldPath, field)
211
+ let failure = ''
212
+ try { prepareSession(root, cfg, event) } catch (e) { failure = e.message }
213
+ const inboxRoot = join(root, cfg.runtime.eventIndexRoot, 'inbox')
214
+ const retained = readdirSync(inboxRoot).filter((x) => x.endsWith('.json')).length
215
+
216
+ field.behavior.packs = ['behaviors/basic.json']
217
+ writeJson(fieldPath, field)
218
+ const pending = listPendingDispatches(root, cfg)
219
+ const recovered = pending.find((x) => x.type === 'dingtalk-agent.run.redelivery')
220
+ const remaining = readdirSync(inboxRoot).filter((x) => x.endsWith('.json')).length
221
+ return {
222
+ output: { failure, retained, pending, remaining }, toolCalls: 3, steps: 5,
223
+ checks: [
224
+ check(failure.includes('不存在') && retained === 1,
225
+ `failure=${failure}; retained=${retained}`),
226
+ check(Boolean(recovered?.launch && recovered?.runId),
227
+ `type=${recovered?.type}; launch=${recovered?.launch}; run=${recovered?.runId}`),
228
+ check(remaining === 0, `remaining=${remaining}`),
229
+ ],
230
+ }
231
+ }
232
+
233
+ function evalDwsStringData() {
234
+ const { root, cfg } = workspace()
235
+ const raw = {
236
+ type: 'event',
237
+ event_corp_id: 'tenant-a',
238
+ data: JSON.stringify({
239
+ type: 'user_im_message_receive_o2o', event_id: 'evt-dws-string-1',
240
+ conversation_id: 'cid-dws-dm', message_id: 'mid-dws-dm',
241
+ sender_open_dingtalk_id: '$:LWCP_v1:$peer', content: '你好',
242
+ }),
243
+ }
244
+ const dispatch = prepareSession(root, cfg, raw)
245
+ const normalized = readJson(join(dispatch.cwd, 'event.json'))
246
+ let failure = ''
247
+ try { prepareSession(root, cfg, { type: 'event', data: '{bad json' }) }
248
+ catch (e) { failure = e.message }
249
+ return {
250
+ output: { dispatch, normalized, failure }, toolCalls: 2, steps: 3,
251
+ checks: [
252
+ check(normalized.kind === 'im.message.dm' && normalized.message.text === '你好',
253
+ `${normalized.kind}/${normalized.message.text}`),
254
+ check(normalized.tenant.id === 'tenant-a' &&
255
+ normalized.replyTarget.conversationId === 'cid-dws-dm' &&
256
+ normalized.replyTarget.messageId === 'mid-dws-dm' &&
257
+ normalized.replyTarget.senderOpenDingTalkId === '$:LWCP_v1:$peer',
258
+ JSON.stringify(normalized.replyTarget)),
259
+ check(failure.includes('data 不是合法 JSON 字符串'), failure),
260
+ ],
261
+ }
262
+ }
263
+
264
+ function evalSessionScope() {
265
+ const { root, cfg } = workspace()
266
+ const base = fixture('mentioned.json')
267
+ const body = JSON.parse(base.data)
268
+ const messageEvent = (eventId, tenantId, messageId, extra = {}) => ({
269
+ event_corp_id: tenantId,
270
+ data: JSON.stringify({ ...body, event_id: eventId, message_id: messageId, ...extra }),
271
+ })
272
+ const firstEvent = messageEvent('evt-topic-1', 'tenant-a', 'mid-topic-1')
273
+ const secondEvent = messageEvent('evt-topic-2', 'tenant-a', 'mid-topic-2')
274
+ const followupEvent = messageEvent('evt-topic-3', 'tenant-a', 'mid-topic-3',
275
+ { root_message_id: 'mid-topic-1' })
276
+ const otherTenantEvent = messageEvent('evt-topic-4', 'tenant-b', 'mid-topic-1')
277
+ const first = prepareSession(root, cfg, firstEvent)
278
+ const second = prepareSession(root, cfg, secondEvent)
279
+ const followup = prepareSession(root, cfg, followupEvent)
280
+ const otherTenant = prepareSession(root, cfg, otherTenantEvent)
281
+ return {
282
+ output: { first, second, followup, otherTenant }, toolCalls: 4, steps: 4,
283
+ checks: [
284
+ check(first.sessionId !== second.sessionId, `${first.sessionId} != ${second.sessionId}`),
285
+ check(first.sessionId === followup.sessionId && first.runId !== followup.runId,
286
+ `${first.sessionId}/${first.runId} -> ${followup.sessionId}/${followup.runId}`),
287
+ check(first.sessionId !== otherTenant.sessionId,
288
+ `${first.sessionId} != ${otherTenant.sessionId}`),
289
+ ],
290
+ }
291
+ }
292
+
293
+ function evalSkillSnapshot() {
294
+ const { root, cfg } = workspace()
295
+ installWorkspaceSkillOverride(root)
296
+ const event = fixture('mentioned.json')
297
+ const first = prepareSession(root, cfg, event)
298
+ const manifestPath = join(first.cwd, 'context', 'skills', 'manifest.json')
299
+ const skillPath = join(first.cwd, 'context', 'skills', 'dingtalk-basic-behavior', 'SKILL.md')
300
+ const manifest = readJson(manifestPath)
301
+ const before = readFileSync(skillPath, 'utf8')
302
+ const source = join(root, 'skills', 'dingtalk-basic-behavior', 'SKILL.md')
303
+ writeFileSync(source, readFileSync(source, 'utf8') + '\n<!-- candidate change -->\n')
304
+ const replay = prepareSession(root, cfg, event)
305
+ const after = readFileSync(skillPath, 'utf8')
306
+ const run = readJson(join(first.cwd, 'run.json'))
307
+ writeFileSync(skillPath, after + '\n<!-- tampered in sandbox -->\n')
308
+ let tamperBlocked = ''
309
+ try { previewAction(first.cwd, 'silence', { reason: 'no_value' }) }
310
+ catch (e) { tamperBlocked = e.message }
311
+ return {
312
+ output: { first, replay, manifest, run, tamperBlocked }, toolCalls: 5, steps: 6,
313
+ checks: [
314
+ check(manifest.entries?.[0]?.name === 'dingtalk-basic-behavior' &&
315
+ before.includes('name: dingtalk-basic-behavior'), JSON.stringify(manifest.entries?.[0])),
316
+ check(run.skills?.manifestHash === manifest.hash, `${run.skills?.manifestHash}/${manifest.hash}`),
317
+ check(before === after && replay.runId === first.runId &&
318
+ tamperBlocked.includes('Skill snapshot 内容漂移'),
319
+ `snapshotSame=${before === after}; run=${replay.runId}; tamper=${tamperBlocked}`),
320
+ ],
321
+ }
322
+ }
323
+
324
+ function evalSingleEgress() {
325
+ const { root, cfg } = workspace()
326
+ const fieldPath = join(root, 'fields', 'default', 'field.json')
327
+ const field = readJson(fieldPath)
328
+ field.transport = { mode: 'robot-connect', egressOwner: 'connector' }
329
+ writeJson(fieldPath, field)
330
+ const dispatch = prepareSession(root, cfg, fixture('mentioned.json'))
331
+ const fieldSnapshot = readJson(join(dispatch.cwd, 'context', 'field.json'))
332
+ let blocked = ''
333
+ try { previewAction(dispatch.cwd, 'reply', { text: '不应由 act 发送' }) }
334
+ catch (e) { blocked = e.message }
335
+ const receipt = executeAction(dispatch.cwd, 'silence', { reason: 'policy_denied' })
336
+ return {
337
+ output: { dispatch, fieldSnapshot, blocked, receipt }, toolCalls: 3, steps: 4,
338
+ checks: [
339
+ check(fieldSnapshot.transport?.egressOwner === 'connector',
340
+ JSON.stringify(fieldSnapshot.transport)),
341
+ check(blocked.includes('出口属于 connector'), blocked),
342
+ check(receipt.kind === 'silence' && receipt.verified === true, JSON.stringify(receipt)),
343
+ ],
344
+ }
345
+ }
346
+
347
+ function evalHeartbeatOccurrences() {
348
+ const { root, cfg } = workspace()
349
+ const firstEvent = heartbeatEvent('duty:daily-review', 'beat-001')
350
+ const secondEvent = heartbeatEvent('duty:daily-review', 'beat-002')
351
+ const first = prepareSession(root, cfg, firstEvent)
352
+ const retry = prepareSession(root, cfg, firstEvent)
353
+ ackDispatch(root, cfg, first.eventId, first.runId, 'eval-controller')
354
+ const second = prepareSession(root, cfg, secondEvent)
355
+ return {
356
+ output: { firstEvent, secondEvent, first, retry, second }, toolCalls: 4, steps: 5,
357
+ checks: [
358
+ check(first.sessionId === second.sessionId,
359
+ `${first.sessionId} == ${second.sessionId}`),
360
+ check(first.runId !== second.runId && second.launch === true,
361
+ `${first.runId} != ${second.runId}; launch=${second.launch}`),
362
+ check(retry.runId === first.runId && retry.duplicate === true,
363
+ `retry=${retry.runId}; duplicate=${retry.duplicate}`),
364
+ ],
365
+ }
366
+ }
367
+
368
+ function evalAskWaitResume() {
369
+ const { root, cfg } = workspace()
370
+ enableFakeDws(root)
371
+ const firstEvent = dmEvent('evt-wait-ask-1', 'mid-wait-ask-1', 'alice', '请先给我一个草稿')
372
+ const first = prepareSession(root, cfg, firstEvent)
373
+ const receipt = withFakeDws(root, () =>
374
+ executeAction(first.cwd, 'ask', { text: '草稿需要偏正式还是偏口语?' }))
375
+ const active = readWaitForEvent(root, cfg, first.fieldId, readJson(join(first.cwd, 'event.json')))
376
+
377
+ const replyEvent = dmEvent('evt-wait-reply-1', 'mid-wait-reply-1', 'alice', '偏正式')
378
+ const resumed = prepareSession(root, cfg, replyEvent)
379
+ const resume = readJson(join(resumed.cwd, 'context', 'resume.json'))
380
+ const claimed = readWaitForEvent(root, cfg, resumed.fieldId, readJson(join(resumed.cwd, 'event.json')))
381
+ const secondReceipt = withFakeDws(root, () =>
382
+ executeAction(resumed.cwd, 'ask', { text: '是否还需要附上数据来源?' }))
383
+ const secondActive = readWaitForEvent(root, cfg, resumed.fieldId, readJson(join(resumed.cwd, 'event.json')))
384
+ const replay = withFakeDws(root, () =>
385
+ executeAction(first.cwd, 'ask', { text: '草稿需要偏正式还是偏口语?' }))
386
+ const afterReplay = readWaitForEvent(root, cfg, resumed.fieldId, readJson(join(resumed.cwd, 'event.json')))
387
+ return {
388
+ output: { first, receipt, active, resumed, resume, claimed,
389
+ secondReceipt, secondActive, replay, afterReplay }, toolCalls: 7, steps: 9,
390
+ checks: [
391
+ check(receipt.kind === 'ask' && active?.status === 'active' &&
392
+ active?.sourceRunId === first.runId,
393
+ `receipt=${receipt.outcome}; wait=${active?.id}/${active?.status}`),
394
+ check(resumed.sessionId === first.sessionId && resumed.runId !== first.runId &&
395
+ resumed.mode === 'resume',
396
+ `${first.sessionId}/${first.runId} -> ${resumed.sessionId}/${resumed.runId}`),
397
+ check(resume.waitId === active?.id && resume.sourceRunId === first.runId &&
398
+ resume.questionText === '草稿需要偏正式还是偏口语?' &&
399
+ claimed?.status === 'claimed' && claimed?.claim?.runId === resumed.runId,
400
+ JSON.stringify({ resume, status: claimed?.status, claim: claimed?.claim })),
401
+ check(replay.duplicate === true && secondActive?.id !== active?.id &&
402
+ afterReplay?.id === secondActive?.id && afterReplay?.status === 'active',
403
+ `old=${active?.id}; current=${secondActive?.id}; afterReplay=${afterReplay?.id}`),
404
+ ],
405
+ }
406
+ }
407
+
408
+ function evalWaitScopeIsolation() {
409
+ const { root, cfg } = workspace()
410
+ enableFakeDws(root)
411
+ const firstEvent = dmEvent('evt-wait-scope-1', 'mid-wait-scope-1', 'alice', '帮我准备材料')
412
+ const first = prepareSession(root, cfg, firstEvent)
413
+ withFakeDws(root, () => executeAction(first.cwd, 'ask', { text: '材料用于哪个会议?' }))
414
+
415
+ const otherActor = prepareSession(root, cfg,
416
+ dmEvent('evt-wait-scope-2', 'mid-wait-scope-2', 'bob', '用于周会'))
417
+ const explicit = prepareSession(root, cfg,
418
+ dmEvent('evt-wait-scope-3', 'mid-wait-scope-3', 'alice', '另开一个事项'),
419
+ { sessionKey: 'explicit-other-work' })
420
+ const resumed = prepareSession(root, cfg,
421
+ dmEvent('evt-wait-scope-4', 'mid-wait-scope-4', 'alice', '用于季度复盘会'))
422
+ return {
423
+ output: { first, otherActor, explicit, resumed }, toolCalls: 5, steps: 6,
424
+ checks: [
425
+ check(otherActor.sessionId !== first.sessionId && otherActor.mode === 'start',
426
+ `other=${otherActor.sessionId}/${otherActor.mode}`),
427
+ check(explicit.sessionId !== first.sessionId && explicit.mode === 'start',
428
+ `explicit=${explicit.sessionId}/${explicit.mode}`),
429
+ check(resumed.sessionId === first.sessionId && resumed.mode === 'resume',
430
+ `resumed=${resumed.sessionId}/${resumed.mode}`),
431
+ ],
432
+ }
433
+ }
434
+
435
+ function evalWaitConflict() {
436
+ const { root, cfg } = workspace()
437
+ enableFakeDws(root)
438
+ const firstEvent = dmEvent('evt-wait-conflict-1', 'mid-wait-conflict-1', 'alice', '事项一')
439
+ const first = prepareSession(root, cfg, firstEvent)
440
+ withFakeDws(root, () => executeAction(first.cwd, 'ask', { text: '事项一缺哪个日期?' }))
441
+ const resumed = prepareSession(root, cfg,
442
+ dmEvent('evt-wait-conflict-2', 'mid-wait-conflict-2', 'alice', '下周一'))
443
+ const claimed = readWaitForEvent(
444
+ root, cfg, first.fieldId, readJson(join(resumed.cwd, 'event.json')))
445
+ const second = prepareSession(root, cfg,
446
+ dmEvent('evt-wait-conflict-3', 'mid-wait-conflict-3', 'alice', '事项二'),
447
+ { sessionKey: 'parallel-work' })
448
+ let blocked = ''
449
+ try {
450
+ withFakeDws(root, () => executeAction(second.cwd, 'ask', { text: '事项二缺哪个负责人?' }))
451
+ } catch (e) { blocked = e.message }
452
+ const current = readWaitForEvent(root, cfg, first.fieldId, readJson(join(first.cwd, 'event.json')))
453
+ return {
454
+ output: { first, resumed, claimed, second, blocked, current }, toolCalls: 5, steps: 6,
455
+ checks: [
456
+ check(first.sessionId !== second.sessionId && resumed.sessionId === first.sessionId,
457
+ `${first.sessionId} != ${second.sessionId}; resumed=${resumed.sessionId}`),
458
+ check(blocked.includes('单链 v1 拒绝并行追问'), blocked),
459
+ check(claimed?.status === 'claimed' && current?.sessionId === first.sessionId &&
460
+ current?.status === 'claimed',
461
+ `${current?.sessionId}/${current?.status}`),
462
+ ],
463
+ }
464
+ }
465
+
466
+ function evalWaitCancelFence() {
467
+ const { root, cfg } = workspace()
468
+ enableFakeDws(root)
469
+ const firstEvent = dmEvent('evt-wait-cancel-1', 'mid-wait-cancel-1', 'alice', '请准备发布稿')
470
+ const first = prepareSession(root, cfg, firstEvent)
471
+ withFakeDws(root, () => executeAction(first.cwd, 'ask', { text: '确认按当前版本发布吗?' }))
472
+
473
+ // 下一条消息已 claim 并物化成 resumed Run,但尚未执行;/stop 仍必须能截断它。
474
+ const resumed = prepareSession(root, cfg,
475
+ dmEvent('evt-wait-cancel-2', 'mid-wait-cancel-2', 'alice', '确认'))
476
+ const beforeStop = readWaitForEvent(
477
+ root, cfg, first.fieldId, readJson(join(resumed.cwd, 'event.json')))
478
+
479
+ // 故意破坏当前 Behavior 引用,证明取消闸门先于 Field/Behavior/Skill 物化执行。
480
+ const fieldPath = join(root, 'fields', 'default', 'field.json')
481
+ const field = readJson(fieldPath)
482
+ writeJson(fieldPath, {
483
+ ...field,
484
+ behavior: { ...(field.behavior || {}), packs: ['behaviors/missing.json'] },
485
+ })
486
+ const stopEvent = dmEvent('evt-wait-cancel-3', 'mid-wait-cancel-3', 'alice', '/stop')
487
+ let interrupted = ''
488
+ try { prepareSession(root, cfg, stopEvent) }
489
+ catch (e) { interrupted = e.message }
490
+ const fencedBeforeMaterialize = readJson(join(first.sessionPath, 'session.json'))
491
+ writeJson(fieldPath, field)
492
+
493
+ // 旧 stop 尚未落 event-index 时,新事项已经在同一 scope 建立 Wait。
494
+ // 重放旧 stop 必须按 eventId ledger 找回旧 Wait,不能误取消这个新 head。
495
+ const newer = prepareSession(root, cfg,
496
+ dmEvent('evt-wait-cancel-4', 'mid-wait-cancel-4', 'alice', '另开一个发布事项'),
497
+ { sessionKey: 'post-cancel-new-work' })
498
+ withFakeDws(root, () => executeAction(
499
+ newer.cwd, 'ask', { text: '新事项按哪个版本?' }))
500
+ const newActiveBeforeReplay = readWaitForEvent(
501
+ root, cfg, newer.fieldId, readJson(join(newer.cwd, 'event.json')))
502
+ const cancelled = prepareSession(root, cfg, stopEvent)
503
+ const duplicate = prepareSession(root, cfg, stopEvent)
504
+ const session = readJson(join(first.sessionPath, 'session.json'))
505
+ const waitAfterReplay = readWaitForEvent(
506
+ root, cfg, newer.fieldId, readJson(join(newer.cwd, 'event.json')))
507
+ let fenced = ''
508
+ try { previewAction(resumed.cwd, 'reply', { text: '已唤醒 Run 不应再发送' }) }
509
+ catch (e) { fenced = e.message }
510
+ return {
511
+ output: { first, resumed, beforeStop, interrupted, fencedBeforeMaterialize,
512
+ newer, newActiveBeforeReplay, cancelled, duplicate, session, waitAfterReplay,
513
+ fenced }, toolCalls: 10, steps: 13,
514
+ checks: [
515
+ check(beforeStop?.status === 'claimed' && cancelled.sessionId === first.sessionId &&
516
+ cancelled.mode === 'cancel' &&
517
+ newActiveBeforeReplay?.sessionId === newer.sessionId &&
518
+ waitAfterReplay?.id === newActiveBeforeReplay?.id &&
519
+ waitAfterReplay?.status === 'active' &&
520
+ sameSet(cancelled.allowedActions, ['reply', 'silence']),
521
+ `before=${beforeStop?.status}; cancel=${cancelled.sessionId}/${cancelled.mode}; ` +
522
+ `new=${waitAfterReplay?.id}/${waitAfterReplay?.status}`),
523
+ check(interrupted.includes('missing.json') && fencedBeforeMaterialize.generation === 2 &&
524
+ session.generation === 2 && duplicate.runId === cancelled.runId,
525
+ `interrupted=${interrupted}; generations=${fencedBeforeMaterialize.generation}/${session.generation}`),
526
+ check(fenced.includes('Session generation 已失效'), fenced),
527
+ ],
528
+ }
529
+ }
530
+
531
+ function evalWaitReceiptRepair() {
532
+ const { root, cfg } = workspace()
533
+ enableFakeDws(root)
534
+ const firstEvent = dmEvent('evt-wait-repair-1', 'mid-wait-repair-1', 'alice', '帮我写总结')
535
+ const first = prepareSession(root, cfg, firstEvent)
536
+ withFakeDws(root, () => executeAction(first.cwd, 'ask', { text: '总结覆盖哪个时间段?' }))
537
+ const event = readJson(join(first.cwd, 'event.json'))
538
+ const active = readWaitForEvent(root, cfg, first.fieldId, event)
539
+
540
+ // 模拟进程在 Receipt 落盘后、Wait 状态更新前崩溃。
541
+ const crashed = {
542
+ ...active,
543
+ status: 'arming',
544
+ delivery: null,
545
+ lastAppliedReceiptActionId: undefined,
546
+ }
547
+ const authorityRoot = waitRoot(root, cfg)
548
+ writeJson(join(authorityRoot, `${active.scopeHash}.json`), crashed)
549
+ writeJson(join(authorityRoot, 'history', `${active.id}.json`), crashed)
550
+
551
+ const resumed = prepareSession(root, cfg,
552
+ dmEvent('evt-wait-repair-2', 'mid-wait-repair-2', 'alice', '本周'))
553
+ const repaired = readWaitForEvent(
554
+ root, cfg, resumed.fieldId, readJson(join(resumed.cwd, 'event.json')))
555
+ const resume = readJson(join(resumed.cwd, 'context', 'resume.json'))
556
+ return {
557
+ output: { first, active, crashed, resumed, repaired, resume }, toolCalls: 4, steps: 6,
558
+ checks: [
559
+ check(active?.status === 'active' && crashed.status === 'arming',
560
+ `${active?.status} -> ${crashed.status}`),
561
+ check(resumed.sessionId === first.sessionId && resumed.mode === 'resume' &&
562
+ repaired?.status === 'claimed',
563
+ `${resumed.sessionId}/${resumed.mode}; wait=${repaired?.status}`),
564
+ check(resume.waitId === active.id && resume.questionText === '总结覆盖哪个时间段?' &&
565
+ repaired?.claim?.runId === resumed.runId,
566
+ JSON.stringify({ resume, claim: repaired?.claim })),
567
+ ],
568
+ }
569
+ }
570
+
571
+ function evalClaimSupersededByCancel() {
572
+ const { root, cfg } = workspace()
573
+ enableFakeDws(root)
574
+ const firstEvent = dmEvent('evt-claim-race-1', 'mid-claim-race-1', 'alice', '帮我发布评价')
575
+ const first = prepareSession(root, cfg, firstEvent)
576
+ withFakeDws(root, () => executeAction(
577
+ first.cwd, 'ask', { text: '确认使用当前评价吗?' }))
578
+
579
+ // 精确构造:回复 E 已绑定并 claim Wait,但尚未来得及 materialize Run;/stop 抢先取消。
580
+ const replyRaw = dmEvent('evt-claim-race-2', 'mid-claim-race-2', 'alice', '确认')
581
+ const replyEvent = normalizeEvent(replyRaw)
582
+ const claimed = claimWait(root, cfg, first.fieldId, replyEvent)
583
+ // 模拟 claimed head 已落盘、event ledger 尚未落盘时 SIGKILL。
584
+ rmSync(join(waitRoot(root, cfg), 'events', `${digest(replyEvent.id)}.json`), { force: true })
585
+ const stopRaw = dmEvent('evt-claim-race-3', 'mid-claim-race-3', 'alice', '/stop')
586
+ const stopEvent = normalizeEvent(stopRaw)
587
+ const cancelled = cancelWaitForEvent(root, cfg, stopEvent)
588
+
589
+ // E 重放只能回到原 Session 的安全 no-op Run,绝不能 fallback 成新事项。
590
+ const suppressed = prepareSession(root, cfg, replyRaw)
591
+ const resume = readJson(join(suppressed.cwd, 'context', 'resume.json'))
592
+ let replyBlocked = ''
593
+ try { previewAction(suppressed.cwd, 'reply', { text: '不应发送' }) }
594
+ catch (e) { replyBlocked = e.message }
595
+ const receipt = executeAction(suppressed.cwd, 'silence', { reason: 'policy_denied' })
596
+ const session = readJson(join(first.sessionPath, 'session.json'))
597
+ return {
598
+ output: { first, claimed, cancelled, suppressed, resume, replyBlocked, receipt, session },
599
+ toolCalls: 7, steps: 9,
600
+ checks: [
601
+ check(claimed?.status === 'claimed' && cancelled?.status === 'cancelled' &&
602
+ session.generation === 2,
603
+ `claim=${claimed?.status}; cancel=${cancelled?.status}; generation=${session.generation}`),
604
+ check(suppressed.sessionId === first.sessionId && suppressed.mode === 'cancel' &&
605
+ resume.suppressedInput === true && resume.cancelEventId === stopEvent.id &&
606
+ sameSet(suppressed.allowedActions, ['silence']),
607
+ JSON.stringify({ sessionId: suppressed.sessionId, mode: suppressed.mode,
608
+ resume, allowed: suppressed.allowedActions })),
609
+ check(replyBlocked.includes('不允许 reply') && receipt.kind === 'silence' &&
610
+ receipt.verified === true,
611
+ `blocked=${replyBlocked}; receipt=${receipt.kind}/${receipt.verified}`),
612
+ ],
613
+ }
614
+ }
615
+
616
+ function evalNegativeWaitRouting() {
617
+ const { root, cfg } = workspace()
618
+ enableFakeDws(root)
619
+
620
+ // Alice 的旧消息第一次 lookup 时没有 Wait:先固化 NO_MATCH,再创建一条新 Wait。
621
+ const oldReplyRaw = dmEvent('evt-negative-1', 'mid-negative-1', 'alice', '旧消息')
622
+ const oldReplyEvent = normalizeEvent(oldReplyRaw)
623
+ const noClaim = claimWait(root, cfg, 'default', oldReplyEvent)
624
+ const aliceWork = prepareSession(root, cfg,
625
+ dmEvent('evt-negative-2', 'mid-negative-2', 'alice', '后来事项'),
626
+ { sessionKey: 'later-alice-work' })
627
+ withFakeDws(root, () => executeAction(
628
+ aliceWork.cwd, 'ask', { text: '后来事项需要哪个日期?' }))
629
+ const aliceBeforeReplay = readWaitForEvent(
630
+ root, cfg, aliceWork.fieldId, readJson(join(aliceWork.cwd, 'event.json')))
631
+ const oldReplyReplay = prepareSession(root, cfg, oldReplyRaw)
632
+ const aliceAfterReplay = readWaitForEvent(
633
+ root, cfg, aliceWork.fieldId, readJson(join(aliceWork.cwd, 'event.json')))
634
+
635
+ // Bob 的旧 stop 第一次没有目标;后来出现的 Wait 不能被旧 stop 重放误杀。
636
+ const oldStopRaw = dmEvent('evt-negative-3', 'mid-negative-3', 'bob', '/stop')
637
+ const noCancel = cancelWaitForEvent(root, cfg, normalizeEvent(oldStopRaw))
638
+ const bobWork = prepareSession(root, cfg,
639
+ dmEvent('evt-negative-4', 'mid-negative-4', 'bob', '后来事项'),
640
+ { sessionKey: 'later-bob-work' })
641
+ withFakeDws(root, () => executeAction(
642
+ bobWork.cwd, 'ask', { text: '后来事项需要哪个负责人?' }))
643
+ const bobBeforeReplay = readWaitForEvent(
644
+ root, cfg, bobWork.fieldId, readJson(join(bobWork.cwd, 'event.json')))
645
+ const oldStopReplay = prepareSession(root, cfg, oldStopRaw)
646
+ const bobAfterReplay = readWaitForEvent(
647
+ root, cfg, bobWork.fieldId, readJson(join(bobWork.cwd, 'event.json')))
648
+ return {
649
+ output: { noClaim, aliceWork, aliceBeforeReplay, oldReplyReplay, aliceAfterReplay,
650
+ noCancel, bobWork, bobBeforeReplay, oldStopReplay, bobAfterReplay },
651
+ toolCalls: 10, steps: 12,
652
+ checks: [
653
+ check(noClaim === null && noCancel === null, `claim=${noClaim}; cancel=${noCancel}`),
654
+ check(oldReplyReplay.mode === 'start' &&
655
+ oldReplyReplay.sessionId !== aliceWork.sessionId &&
656
+ aliceAfterReplay?.id === aliceBeforeReplay?.id && aliceAfterReplay?.status === 'active',
657
+ `oldReply=${oldReplyReplay.mode}/${oldReplyReplay.sessionId}; ` +
658
+ `newWait=${aliceAfterReplay?.id}/${aliceAfterReplay?.status}`),
659
+ check(oldStopReplay.mode === 'start' && oldStopReplay.sessionId !== bobWork.sessionId &&
660
+ bobAfterReplay?.id === bobBeforeReplay?.id && bobAfterReplay?.status === 'active',
661
+ `oldStop=${oldStopReplay.mode}/${oldStopReplay.sessionId}; ` +
662
+ `newWait=${bobAfterReplay?.id}/${bobAfterReplay?.status}`),
663
+ ],
664
+ }
665
+ }
666
+
667
+ function evalWorkspaceAuthority() {
668
+ const { root, cfg } = workspace()
669
+ enableFakeDws(root)
670
+ const workspacePath = join(root, '.dingtalk-agent', 'workspace.json')
671
+ const manifest = readJson(workspacePath)
672
+ manifest.dws = { profile: 'workspace-profile', expectedUserId: 'workspace-agent' }
673
+ manifest.mounts.memory = 'dingtalk-doc:node-memory'
674
+ writeJson(workspacePath, manifest)
675
+
676
+ const dispatch = withFakeDws(root, () =>
677
+ prepareSession(root, cfg, fixture('mentioned.json'), { contextId: manifest.contextId }))
678
+ const field = readJson(join(dispatch.cwd, 'context', 'field.json'))
679
+ const mounts = readJson(join(dispatch.cwd, 'context', 'mounts', 'manifest.json'))
680
+ const memory = readFileSync(join(dispatch.cwd, 'context', 'mounts', 'memory.md'), 'utf8')
681
+ const trigger = readJson(join(dispatch.cwd, 'trigger.json'))
682
+ let wrongContext = ''
683
+ try { prepareSession(root, cfg, fixture('mentioned.json'), { contextId: 'wrong-context' }) }
684
+ catch (e) { wrongContext = e.message }
685
+ return {
686
+ output: { dispatch, field: field.dws, mounts, memory, trigger, wrongContext },
687
+ toolCalls: 2, steps: 5,
688
+ checks: [
689
+ check(trigger.contextId === manifest.contextId && dispatch.contextId === manifest.contextId,
690
+ `${trigger.contextId}/${dispatch.contextId}`),
691
+ check(mounts.entries.some((x) => x.slot === 'memory' &&
692
+ x.provider === 'dingtalk-doc') && memory.includes('Remote Memory'),
693
+ `${JSON.stringify(mounts.entries)}; memory=${memory.trim()}`),
694
+ check(field.dws?.profile === 'workspace-profile' &&
695
+ field.dws?.expectedUserId === 'workspace-agent' &&
696
+ wrongContext.includes('不属于当前 Workspace'),
697
+ `${JSON.stringify(field.dws)}; wrong=${wrongContext}`),
698
+ ],
699
+ }
700
+ }
701
+
702
+ function evalSessionSkillBinding() {
703
+ const { root, cfg } = workspace()
704
+ installWorkspaceSkillOverride(root)
705
+ const firstRaw = fixture('mentioned.json')
706
+ const first = prepareSession(root, cfg, firstRaw)
707
+ const firstSkill = join(first.cwd, 'context', 'skills', 'dingtalk-basic-behavior', 'SKILL.md')
708
+ const before = readFileSync(firstSkill, 'utf8')
709
+ const source = join(root, 'skills', 'dingtalk-basic-behavior', 'SKILL.md')
710
+ writeFileSync(source, readFileSync(source, 'utf8') + '\n<!-- next-session-only -->\n')
711
+
712
+ const followBody = JSON.parse(firstRaw.data)
713
+ followBody.event_id = 'evt-mentioned-followup-001'
714
+ followBody.message_id = 'mid-mentioned-followup-001'
715
+ followBody.root_message_id = 'mid-mentioned-001'
716
+ followBody.content = '确认,继续原事项'
717
+ const follow = prepareSession(root, cfg, { ...firstRaw, data: JSON.stringify(followBody) })
718
+ const followSkill = readFileSync(join(
719
+ follow.cwd, 'context', 'skills', 'dingtalk-basic-behavior', 'SKILL.md'), 'utf8')
720
+
721
+ const nextBody = { ...followBody,
722
+ event_id: 'evt-mentioned-new-session-001',
723
+ message_id: 'mid-mentioned-new-session-001',
724
+ root_message_id: '',
725
+ content: '这是另一件事',
726
+ }
727
+ const next = prepareSession(root, cfg, { ...firstRaw, data: JSON.stringify(nextBody) })
728
+ const nextSkill = readFileSync(join(
729
+ next.cwd, 'context', 'skills', 'dingtalk-basic-behavior', 'SKILL.md'), 'utf8')
730
+ const session = readJson(join(first.sessionPath, 'session.json'))
731
+ return {
732
+ output: { first, follow, next, sessionSkills: session.skills },
733
+ toolCalls: 3, steps: 6,
734
+ checks: [
735
+ check(follow.sessionId === first.sessionId && follow.runId !== first.runId,
736
+ `${first.sessionId}/${first.runId} -> ${follow.sessionId}/${follow.runId}`),
737
+ check(before === followSkill && !followSkill.includes('next-session-only') &&
738
+ Boolean(session.skills?.manifestHash),
739
+ `same=${before === followSkill}; manifest=${session.skills?.manifestHash}`),
740
+ check(next.sessionId !== first.sessionId && nextSkill.includes('next-session-only'),
741
+ `${next.sessionId}; updated=${nextSkill.includes('next-session-only')}`),
742
+ ],
743
+ }
744
+ }
745
+
746
+ function evalGlobalSkillInstall() {
747
+ const home = mkdtempSync(join(tmpdir(), 'dta-skill-home-'))
748
+ const cwd = mkdtempSync(join(tmpdir(), 'dta-skill-cwd-'))
749
+ const foreignHome = mkdtempSync(join(tmpdir(), 'dta-skill-foreign-'))
750
+ WORKSPACES.push(home, cwd, foreignHome)
751
+ const env = { ...process.env, HOME: home }
752
+ const first = spawnSync(process.execPath, [CLI, 'skill', 'install', '--json'], {
753
+ cwd, env, encoding: 'utf8',
754
+ })
755
+ const second = spawnSync(process.execPath, [CLI, 'skill', 'install', '--json'], {
756
+ cwd, env, encoding: 'utf8',
757
+ })
758
+ const status = JSON.parse(second.stdout)
759
+ const canonical = join(home, '.agents', 'skills', 'dingtalk-basic-behavior')
760
+ const claude = join(home, '.claude', 'skills', 'dingtalk-basic-behavior')
761
+
762
+ const foreign = join(foreignHome, '.agents', 'skills', 'dingtalk-basic-behavior')
763
+ mkdirSync(foreign, { recursive: true })
764
+ writeFileSync(join(foreign, 'SKILL.md'), '---\nname: dingtalk-basic-behavior\ndescription: foreign\n---\n')
765
+ const conflict = spawnSync(process.execPath, [CLI, 'skill', 'install'], {
766
+ cwd, env: { ...process.env, HOME: foreignHome }, encoding: 'utf8',
767
+ })
768
+ return {
769
+ output: {
770
+ firstStatus: first.status, secondStatus: second.status, status,
771
+ claudeTarget: readlinkSync(claude), conflictStatus: conflict.status,
772
+ conflictError: conflict.stderr.trim(),
773
+ },
774
+ toolCalls: 3, steps: 6,
775
+ checks: [
776
+ check(first.status === 0 && existsSync(join(canonical, 'SKILL.md')),
777
+ `status=${first.status}; canonical=${canonical}`),
778
+ check(second.status === 0 && status.canonical.current === true &&
779
+ status.canonical.modified === false,
780
+ `status=${second.status}; current=${status.canonical.current}; modified=${status.canonical.modified}`),
781
+ check(lstatSync(claude).isSymbolicLink() &&
782
+ readlinkSync(claude) === '../../.agents/skills/dingtalk-basic-behavior',
783
+ `target=${readlinkSync(claude)}`),
784
+ check(conflict.status !== 0 && conflict.stderr.includes('拒绝覆盖'), conflict.stderr.trim()),
785
+ ],
786
+ }
787
+ }
788
+
789
+ function evalBootstrapLocal() {
790
+ const root = mkdtempSync(join(tmpdir(), 'dta-bootstrap-local-'))
791
+ WORKSPACES.push(root)
792
+ mkdirSync(join(root, 'knowledge'), { recursive: true })
793
+ writeFileSync(join(root, 'WORKSPACE.md'), '# Identity\n')
794
+ writeFileSync(join(root, 'MEMORY.md'), '# Memory\n')
795
+ writeFileSync(join(root, 'knowledge', 'INDEX.md'), '# Knowledge\n')
796
+ const run = spawnSync(process.execPath, [CLI, 'bootstrap', '--json'], {
797
+ cwd: root, encoding: 'utf8',
798
+ })
799
+ const out = JSON.parse(run.stdout)
800
+ return {
801
+ output: out, toolCalls: 1, steps: 3,
802
+ checks: [
803
+ check(run.status === 0 && out.mode === 'direct' && out.initialized === false,
804
+ `status=${run.status}; mode=${out.mode}; initialized=${out.initialized}`),
805
+ check(out.mounts.map((x) => x.slot).join(',') === 'profile,memory,knowledge',
806
+ JSON.stringify(out.mounts)),
807
+ check(!existsSync(join(root, '.dingtalk-agent')), 'bootstrap 未创建 .dingtalk-agent'),
808
+ ],
809
+ }
810
+ }
811
+
812
+ function evalBootstrapRemote() {
813
+ const root = mkdtempSync(join(tmpdir(), 'dta-bootstrap-remote-'))
814
+ WORKSPACES.push(root)
815
+ const bin = enableFakeDws(root)
816
+ const state = join(root, '.state')
817
+ const run = spawnSync(process.execPath, [
818
+ CLI, 'bootstrap', '--storage', 'dingtalk-doc:node-memory',
819
+ '--state-dir', state, '--json',
820
+ ], {
821
+ cwd: root,
822
+ env: { ...process.env, PATH: `${bin}:${process.env.PATH || ''}` },
823
+ encoding: 'utf8',
824
+ })
825
+ const out = JSON.parse(run.stdout)
826
+ const snapshot = out.mounts?.[0]?.path || ''
827
+ return {
828
+ output: { out, snapshot: snapshot ? readFileSync(snapshot, 'utf8') : '', stderr: run.stderr },
829
+ toolCalls: 1, steps: 4,
830
+ checks: [
831
+ check(run.status === 0 && out.mode === 'direct' && out.initialized === false,
832
+ `status=${run.status}; mode=${out.mode}; initialized=${out.initialized}`),
833
+ check(out.mounts.length === 1 && out.mounts[0].provider === 'dingtalk-doc' &&
834
+ readFileSync(snapshot, 'utf8').includes('Remote Memory'), JSON.stringify(out.mounts)),
835
+ check(existsSync(join(dirname(snapshot), 'manifest.json')) &&
836
+ !existsSync(join(root, '.dingtalk-agent', 'workspace.json')),
837
+ `manifest=${join(dirname(snapshot), 'manifest.json')}`),
838
+ ],
839
+ }
840
+ }
841
+
842
+ function evalOptionalInit() {
843
+ const root = mkdtempSync(join(tmpdir(), 'dta-init-optional-'))
844
+ WORKSPACES.push(root)
845
+ const first = spawnSync(process.execPath, [CLI, 'init'], { cwd: root, encoding: 'utf8' })
846
+ const workspaceFile = join(root, '.dingtalk-agent', 'workspace.json')
847
+ const before = readFileSync(workspaceFile, 'utf8')
848
+ const second = spawnSync(process.execPath, [CLI, 'init'], { cwd: root, encoding: 'utf8' })
849
+ const after = readFileSync(workspaceFile, 'utf8')
850
+ const manifest = JSON.parse(after)
851
+ return {
852
+ output: { first: first.stdout, second: second.stdout, manifest },
853
+ toolCalls: 2, steps: 4,
854
+ checks: [
855
+ check(first.status === 0 && manifest.contextId === root.split('/').pop(),
856
+ `status=${first.status}; context=${manifest.contextId}`),
857
+ check(before === after && second.stdout.includes('Workspace 已存在'),
858
+ `same=${before === after}; second=${second.stdout.trim()}`),
859
+ check(!existsSync(join(root, 'AGENTS.md')) && !existsSync(join(root, 'CLAUDE.md')) &&
860
+ !existsSync(join(root, 'ontology')) && !existsSync(join(root, 'skills')),
861
+ 'init 未写 AGENTS.md/CLAUDE.md/ontology/skills'),
862
+ check(manifest.skills[0] === 'dingtalk-basic-behavior', JSON.stringify(manifest.skills)),
863
+ ],
864
+ }
865
+ }
866
+
867
+ function evalManagedSkillLifecycle() {
868
+ const home = mkdtempSync(join(tmpdir(), 'dta-skill-lifecycle-'))
869
+ const cwd = mkdtempSync(join(tmpdir(), 'dta-skill-lifecycle-cwd-'))
870
+ WORKSPACES.push(home, cwd)
871
+ const env = { ...process.env, HOME: home }
872
+ spawnSync(process.execPath, [CLI, 'skill', 'install'], { cwd, env, encoding: 'utf8' })
873
+ const canonical = join(home, '.agents', 'skills', 'dingtalk-basic-behavior')
874
+ const file = join(canonical, 'SKILL.md')
875
+ writeFileSync(file, readFileSync(file, 'utf8') + '\n<!-- local edit -->\n')
876
+ const statusRun = spawnSync(process.execPath, [CLI, 'skill', 'status', '--json'], {
877
+ cwd, env, encoding: 'utf8',
878
+ })
879
+ const modified = JSON.parse(statusRun.stdout)
880
+ const guardedUpgrade = spawnSync(process.execPath, [CLI, 'skill', 'upgrade'], {
881
+ cwd, env, encoding: 'utf8',
882
+ })
883
+ const guardedUninstall = spawnSync(process.execPath, [CLI, 'skill', 'uninstall'], {
884
+ cwd, env, encoding: 'utf8',
885
+ })
886
+ const repaired = spawnSync(process.execPath, [
887
+ CLI, 'skill', 'upgrade', '--discard-local-changes', '--json',
888
+ ], { cwd, env, encoding: 'utf8' })
889
+ const repairedStatus = JSON.parse(repaired.stdout)
890
+ const removed = spawnSync(process.execPath, [CLI, 'skill', 'uninstall', '--json'], {
891
+ cwd, env, encoding: 'utf8',
892
+ })
893
+ const removedStatus = JSON.parse(removed.stdout)
894
+ return {
895
+ output: {
896
+ modified, guardedUpgrade: guardedUpgrade.stderr.trim(),
897
+ guardedUninstall: guardedUninstall.stderr.trim(), repairedStatus, removedStatus,
898
+ },
899
+ toolCalls: 6, steps: 7,
900
+ checks: [
901
+ check(modified.canonical.modified === true, JSON.stringify(modified.canonical)),
902
+ check(guardedUpgrade.status !== 0 && guardedUpgrade.stderr.includes('本地修改'),
903
+ guardedUpgrade.stderr.trim()),
904
+ check(guardedUninstall.status !== 0 && guardedUninstall.stderr.includes('本地修改'),
905
+ guardedUninstall.stderr.trim()),
906
+ check(repaired.status === 0 && repairedStatus.canonical.current === true &&
907
+ removed.status === 0 && removedStatus.canonical.exists === false &&
908
+ removedStatus.claude.state === 'missing',
909
+ `repaired=${repairedStatus.canonical.current}; removed=${removedStatus.canonical.exists}; ` +
910
+ `claude=${removedStatus.claude.state}`),
911
+ ],
912
+ }
913
+ }
914
+
915
+ function evalRemoteStorageTypeGate() {
916
+ const root = mkdtempSync(join(tmpdir(), 'dta-bootstrap-wrong-type-'))
917
+ WORKSPACES.push(root)
918
+ const bin = enableFakeDws(root)
919
+ const run = spawnSync(process.execPath, [
920
+ CLI, 'bootstrap', '--storage', 'dingtalk-doc:sheet-node',
921
+ '--state-dir', join(root, '.state'), '--json',
922
+ ], {
923
+ cwd: root,
924
+ env: {
925
+ ...process.env,
926
+ PATH: `${bin}:${process.env.PATH || ''}`,
927
+ DTA_FAKE_DOC_EXTENSION: 'axls',
928
+ },
929
+ encoding: 'utf8',
930
+ })
931
+ return {
932
+ output: { status: run.status, stderr: run.stderr.trim() }, toolCalls: 1, steps: 2,
933
+ checks: [
934
+ check(run.status !== 0 && run.stderr.includes('只接受在线文档 adoc'), run.stderr.trim()),
935
+ check(!existsSync(join(root, '.state')), '类型不符时没有创建远端快照目录'),
936
+ ],
937
+ }
938
+ }
939
+
940
+ function evalDirectActGate() {
941
+ const root = mkdtempSync(join(tmpdir(), 'dta-direct-act-'))
942
+ WORKSPACES.push(root)
943
+ const run = spawnSync(process.execPath, [CLI, 'act', 'reply', '--text', '越权回复'], {
944
+ cwd: root, encoding: 'utf8',
945
+ })
946
+ return {
947
+ output: { status: run.status, stderr: run.stderr.trim() }, toolCalls: 1, steps: 2,
948
+ checks: [
949
+ check(run.status !== 0 && run.stderr.includes('找不到当前 Run'), run.stderr.trim()),
950
+ check(!existsSync(join(root, '.dingtalk-agent')), 'act fail closed 时不自动 init'),
951
+ ],
952
+ }
953
+ }
954
+
955
+ function dmEvent(eventId, messageId, actor, text, extra = {}) {
956
+ return {
957
+ event_corp_id: extra.tenantId || 'tenant-wait-eval',
958
+ data: JSON.stringify({
959
+ type: 'user_im_message_receive_o2o',
960
+ event_id: eventId,
961
+ conversation_id: extra.conversationId || 'cid-wait-eval',
962
+ message_id: messageId,
963
+ sender: actor,
964
+ sender_open_dingtalk_id: `$:LWCP_v1:$${actor}`,
965
+ content: text,
966
+ }),
967
+ }
968
+ }
969
+
970
+ function enableFakeDws(root) {
971
+ const fieldPath = join(root, 'fields', 'default', 'field.json')
972
+ if (existsSync(fieldPath)) {
973
+ const field = readJson(fieldPath)
974
+ field.dws = { profile: 'eval-profile', expectedUserId: 'agent-eval' }
975
+ writeJson(fieldPath, field)
976
+ }
977
+
978
+ const bin = join(root, '.fake-bin')
979
+ mkdirSync(bin, { recursive: true })
980
+ const script = join(bin, 'dws')
981
+ writeFileSync(script, `#!/usr/bin/env node
982
+ const a = process.argv.slice(2)
983
+ let data = {}
984
+ if (a.includes('get-self')) data = { userId: 'agent-eval' }
985
+ else if (a.includes('doc') && a.includes('read')) data = { markdown: '# Remote Memory\\n\\n来自钉钉文档。' }
986
+ else if (a.includes('doc') && a.includes('info')) data = { contentType: 'ALIDOC', extension: process.env.DTA_FAKE_DOC_EXTENSION || 'adoc', nodeType: 'file' }
987
+ else if (a.includes('reply')) data = { openMessageId: 'fake-sent-message' }
988
+ else if (a.includes('list-by-ids')) data = { messages: [{ openMessageId: 'fake-sent-message' }] }
989
+ console.log(JSON.stringify({ data }))
990
+ `)
991
+ chmodSync(script, 0o755)
992
+ return bin
993
+ }
994
+
995
+ function withFakeDws(root, fn) {
996
+ const before = process.env.PATH || ''
997
+ process.env.PATH = `${join(root, '.fake-bin')}:${before}`
998
+ try { return fn() } finally { process.env.PATH = before }
999
+ }
1000
+
1001
+ function installWorkspaceSkillOverride(root) {
1002
+ cpSync(
1003
+ join(ROOT, 'skills', 'dingtalk-basic-behavior'),
1004
+ join(root, 'skills', 'dingtalk-basic-behavior'),
1005
+ { recursive: true },
1006
+ )
1007
+ }
1008
+
1009
+ function workspace() {
1010
+ const root = mkdtempSync(join(tmpdir(), 'dta-eval-'))
1011
+ WORKSPACES.push(root)
1012
+ const log = console.log
1013
+ console.log = () => {}
1014
+ try { init(root) } finally { console.log = log }
1015
+ return { root, cfg: config.must(root) }
1016
+ }
1017
+
1018
+ function fixture(name) {
1019
+ return JSON.parse(readFileSync(join(HERE, 'fixtures', name), 'utf8'))
1020
+ }
1021
+
1022
+ function sameSet(a, b) {
1023
+ return a.length === b.length && a.every((x) => b.includes(x))
1024
+ }
1025
+
1026
+ function check(passed, evidence) { return { passed, evidence } }
1027
+
1028
+ function writeJson(path, value) {
1029
+ mkdirSync(dirname(path), { recursive: true })
1030
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n')
1031
+ }