agentworkshop 0.7.37 → 0.7.38

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 (59) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/builds/latest.json +1 -1
  3. package/.output/public/_nuxt/builds/meta/44b66249-54d0-4c8a-9057-9755a32329e2.json +1 -0
  4. package/.output/server/chunks/_/nitro.mjs +798 -799
  5. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  6. package/.output/server/package.json +1 -1
  7. package/README-zh.md +2 -2
  8. package/README.md +2 -2
  9. package/docs/cli.en.md +2 -2
  10. package/docs/cli.md +2 -2
  11. package/docs/full-test-plan.md +1 -1
  12. package/docs/plugins.en.md +1 -1
  13. package/docs/plugins.md +1 -1
  14. package/docs/sdk.en.md +1 -1
  15. package/docs/sdk.md +1 -1
  16. package/package.json +1 -1
  17. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.continue.json +1 -0
  18. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.json +1 -0
  19. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/054bb4bc4e4217a2ab0b0c6b663747214a9af464f5568ea60e784704e340bb0f.source +153 -0
  20. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/0554197c3d2bac203a1f5c48a5d52495d1336db1bd7aa4f4c19875810363e684.source +153 -0
  21. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/05c203d12a954fffba942e1d626e3f5fec05b18a5aef2bac003b7884a4b77406.source +431 -0
  22. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/145a6e6ba811af35aed31e0cc3211f53706a14980fa069ee9e3c1591139f96f0.source +180 -0
  23. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/1ae0fb281f97899c025e368ca73f41349b01fb5f85b19e08d7ebccff49095ed5.source +112 -0
  24. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/20ee9967aa0541c516db7575bdd4a1a745939bc7016bd10d7635903113edb8ec.source +227 -0
  25. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/233751843aeba9d4c5263f275b8102de00443c06f286918cbd23e0b6729e01aa.source +131 -0
  26. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/263c2e05b425e5c322e60c6475279f963bab3a873eb521b21df7fec935a46597.source +160 -0
  27. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/2ac5eaac28eaaee2178c4b32014df8e732f9dd432a285a872f5b7f31c1395fa4.source +157 -0
  28. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/33a0db0d2d95d2c11d0720090458e5fba1425376f5ceb5b422d1d3471be3226b.source +348 -0
  29. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/359e474ae83db35e4615d3728de6bf694b64d4dce9b536730a3b6518f9c0494a.source +296 -0
  30. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/37a4c3c31c4b70f6b719c53d1e9c4fcba603f31adfdd11bb6ef9f0d094923c56.source +285 -0
  31. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/461feca1c14a695eb8b0f17a6f95fe97eeceabc0510faaea4ba6d25bde147d8d.source +217 -0
  32. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/4cfadbda8591f29d5e1e230dc000e701b756e850441b3900fb596a4d2f6e98c2.source +301 -0
  33. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/50ca2810cf0b248c96948994c0c404531bbad6e553166b6816b3b03bfa0f75d2.source +151 -0
  34. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/52d9e8cb591a9ae86e238c3620a610cd386717bee222c0f37b25b2ac7e523dc4.source +135 -0
  35. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/6c87daafa4c006560a954636a97333f6123153c9d34d96203f7ee3a14e556357.source +156 -0
  36. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/77bc47854b577900621cacf1697f81fa2bc005af129d37614a97a3441c08f99f.source +72 -0
  37. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/7bbb789d3a527817118cf8399ed1861d6a962ade10878fd07223435d333a7089.source +469 -0
  38. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/86b7c1daafc8b60ba9982dde5bf1bcc74265709483c176e71f23ec6fc267451f.source +259 -0
  39. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/8df66d62bd41984d0970e8236882080c5085ac937d07a29779463d6a03b37c76.source +97 -0
  40. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/9962f2d1b3a85137bd40cc835725600b39f89ee02a27c06b2e0402e1eafde115.source +277 -0
  41. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a1090ea066b792cb201739f4994e37bf7c12f52e805239a1eab87f18a537f266.source +171 -0
  42. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a5241091650134d0919897e086a606a3846c8b2b54f8b4c84a8696c145ac3e9e.source +425 -0
  43. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/af19fa4c6e34d5540028be4b17600161283af3a2a2dfc61a3fdc0834436a0c14.source +99 -0
  44. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3713d78e2b2f93905ff6be8d58a8d0a2f42dea322eb67c987fda22ff6a084d5.source +325 -0
  45. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3849943c3aff102d1af815631fc843a2a52c17646a4b04696ece323e483bbec.source +332 -0
  46. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/bac735a3803a6650b7f1eb8f587e6dc5c68ea055000c3c99a11789438b78e837.source +43 -0
  47. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d32c24d61eb8b33c73e5a8fc7e4dc7ef437aed238ee4371c2b00617e6104090a.source +154 -0
  48. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d5170dc9a9879fda04fdf91d8a110d4efb6787ee38dda596d4da899883d36a72.source +531 -0
  49. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/dc59bcd7bb397be63ba2f2c3fec23f3782dcb17d2d75a9981eeced761f33eb32.source +439 -0
  50. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/eb5ff8cf66b92aec7bf6726781d8df74faa2b030c3aaac17d6b00c1fc04c9b32.source +323 -0
  51. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f10615a6fae18436e70d1201b2d44fdae3174a3aa7bb4db97dea1b3ed291d721.source +433 -0
  52. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f36d3e271668bc1260c2823542d22dbf1d0f7041ff16b1ac175bce2256e582ca.source +280 -0
  53. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f94449380af09f31ac352152007146a75cc80bae8d6411ffe57b2c56f933b0ff.source +117 -0
  54. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/fe4dfd81178fd6b648335f6405d9ede71319aebde796008e50db21f8adf44c99.source +160 -0
  55. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/ffb4e3aa548cfdadabaa44ab599f9b6a3ca6288176a2cc7a4b2fc2a12b5fb848.source +101 -0
  56. package/scripts/test-plugin-hardening.mjs +32 -0
  57. package/server/plugins/dev-stability-guard.ts +40 -63
  58. package/server/utils/stability-severity.ts +66 -0
  59. package/.output/public/_nuxt/builds/meta/a66fdef7-dcfd-453b-b1f6-543c7dfd98d5.json +0 -1
@@ -0,0 +1,135 @@
1
+ /**
2
+ * ScriptBrain 模拟 Agent — 纯逻辑单测(node + tsx 直跑,无浏览器依赖)
3
+ *
4
+ * 验证状态机迁移与动作输出:
5
+ * 1. 初始 tick → 进入 wander + move 动作
6
+ * 2. 玩家进入 6 tiles → approach + 朝玩家方向移动
7
+ * 3. 玩家贴脸(≤3 tiles)→ talk + dialog 发起 + face
8
+ * 4. dialog.closed 事件 → wait,冷却后恢复 wander
9
+ * 5. 地图边缘 → 不朝边界外走
10
+ * 6. 对话中 → 不产出 move
11
+ */
12
+ import { ScriptBrain } from '../server/services/game/agent'
13
+ import type { AgentContext, Dir } from '../server/types/game'
14
+
15
+ let failures = 0
16
+ function check(name: string, ok: boolean, detail = '') {
17
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
18
+ if (!ok)
19
+ failures += 1
20
+ }
21
+
22
+ const AGENT_START = { x: 720, y: 656 }
23
+ const rngSeq = (() => {
24
+ let i = 0
25
+ const seq = [0.9, 0.1, 0.5, 0.7, 0.3, 0.6, 0.2, 0.8, 0.4, 0.95]
26
+ return () => seq[i++ % seq.length]
27
+ })()
28
+
29
+ function mkCtx(over: Partial<AgentContext> = {}): AgentContext {
30
+ return {
31
+ tick: 1,
32
+ now: 1000,
33
+ agent: { ...AGENT_START, dir: 'down', mode: 'idle' },
34
+ player: { x: 0, y: 0, lastSeenAt: null, distance: Infinity },
35
+ rng: rngSeq,
36
+ ...over,
37
+ }
38
+ }
39
+
40
+ // 1. 初始:进入 wander 并产出 move
41
+ {
42
+ const brain = new ScriptBrain()
43
+ const acts = brain.think(mkCtx())
44
+ const state = acts.find(a => a.kind === 'state')
45
+ const move = acts.find(a => a.kind === 'move')
46
+ check('初始 tick 进入 wander', state?.kind === 'state' && state.mode === 'wander')
47
+ check('初始 tick 产出移动动作', move?.kind === 'move' && (move as { durationMs: number }).durationMs > 0,
48
+ `dir=${(move as { dir: Dir }).dir} dur=${(move as { durationMs: number }).durationMs}ms`)
49
+ }
50
+
51
+ // 2. 玩家 5 tiles:approach + 朝玩家方向
52
+ {
53
+ const brain = new ScriptBrain()
54
+ brain.think(mkCtx()) // 进入 wander
55
+ const ctx = mkCtx({
56
+ now: 2000,
57
+ player: { x: AGENT_START.x + 160, y: AGENT_START.y, lastSeenAt: 1999, distance: 160 },
58
+ })
59
+ const acts = brain.think(ctx)
60
+ const state = acts.find(a => a.kind === 'state')
61
+ const move = acts.find(a => a.kind === 'move')
62
+ check('玩家 5 tiles → approach', state?.kind === 'state' && state.mode === 'approach')
63
+ check('approach 朝玩家方向(right)', move?.kind === 'move' && (move as { dir: Dir }).dir === 'right')
64
+ }
65
+
66
+ // 3. 玩家贴脸:talk + dialog
67
+ {
68
+ const brain = new ScriptBrain()
69
+ brain.think(mkCtx())
70
+ const ctx = mkCtx({
71
+ now: 3000,
72
+ player: { x: AGENT_START.x + 64, y: AGENT_START.y, lastSeenAt: 2999, distance: 64 },
73
+ })
74
+ const acts = brain.think(ctx)
75
+ const dialog = acts.find(a => a.kind === 'dialog')
76
+ const move = acts.find(a => a.kind === 'move')
77
+ check('贴脸发起对话', dialog?.kind === 'dialog' && (dialog as { lines: string[] }).lines.length >= 1)
78
+ check('对话中不产出移动', move === undefined)
79
+ // 继续 tick:仍 talk,无新 dialog
80
+ const acts2 = brain.think(mkCtx({ now: 3100, player: { x: AGENT_START.x + 64, y: AGENT_START.y, lastSeenAt: 3099, distance: 64 } }))
81
+ check('对话期间不重复发起', !acts2.some(a => a.kind === 'dialog'))
82
+ }
83
+
84
+ // 4. 对话关闭 → wait → 冷却后 wander
85
+ {
86
+ const brain = new ScriptBrain()
87
+ brain.think(mkCtx())
88
+ brain.think(mkCtx({ now: 3000, player: { x: AGENT_START.x + 64, y: AGENT_START.y, lastSeenAt: 2999, distance: 64 } }))
89
+ brain.onEvent?.({ kind: 'dialog.closed' })
90
+ const during = brain.think(mkCtx({ now: 3500, player: { x: AGENT_START.x + 64, y: AGENT_START.y, lastSeenAt: 3499, distance: 64 } }))
91
+ check('dialog.closed → 无动作(wait 冷却)', during.length === 0 || !during.some(a => a.kind === 'move'))
92
+ const after = brain.think(mkCtx({ now: 9000, player: { x: AGENT_START.x + 64, y: AGENT_START.y, lastSeenAt: 8999, distance: 64 } }))
93
+ const state = after.find(a => a.kind === 'state')
94
+ check('冷却后恢复 wander', state?.kind === 'state' && state.mode === 'wander')
95
+ }
96
+
97
+ // 5. 地图边缘:不向界外走
98
+ {
99
+ const brain = new ScriptBrain()
100
+ const ctx = mkCtx({
101
+ now: 5000,
102
+ agent: { x: 40, y: 656, dir: 'down', mode: 'wander' }, // 左边缘 40px(<2 tiles=64)
103
+ })
104
+ // 固定 rng 序列首个 0.9 → 可能选 left;边缘保护应避免向界外
105
+ for (let i = 0; i < 20; i++) {
106
+ const acts = brain.think(ctx)
107
+ const move = acts.find(a => a.kind === 'move')
108
+ if (move?.kind === 'move' && move.dir === 'left') {
109
+ check('地图边缘不向左走', false, `tick ${i} 仍向左`)
110
+ break
111
+ }
112
+ if (i === 19)
113
+ check('地图边缘不向左走', true)
114
+ }
115
+ }
116
+
117
+ // 6. 玩家远离(>11 tiles) → 放弃追逐回 wander
118
+ {
119
+ const brain = new ScriptBrain()
120
+ brain.think(mkCtx()) // wander
121
+ brain.think(mkCtx({
122
+ now: 2000,
123
+ player: { x: AGENT_START.x + 160, y: AGENT_START.y, lastSeenAt: 1999, distance: 160 },
124
+ })) // approach
125
+ const far = mkCtx({
126
+ now: 3000,
127
+ player: { x: AGENT_START.x + 400, y: AGENT_START.y + 400, lastSeenAt: 2999, distance: 566 },
128
+ })
129
+ const acts = brain.think(far)
130
+ const state = acts.find(a => a.kind === 'state')
131
+ check('玩家远离 → 回 wander', state?.kind === 'state' && state.mode === 'wander')
132
+ }
133
+
134
+ console.log(`\n===== ${6 - failures}/6 passed =====`)
135
+ process.exit(failures ? 1 : 0)
@@ -0,0 +1,156 @@
1
+ /**
2
+ * CMDParser 协议解析测试 + MCP 接缝验证(node + tsx 直跑,无浏览器)
3
+ *
4
+ * 覆盖:
5
+ * 1. 协议同步(assertProtocolSync):JSON 注册表 ↔ wire 类型键集合一致,example 自洽
6
+ * 2. CMDParser.parseUplink:合法/空/坏JSON/缺字段/未知指令/无效枚举/越界值
7
+ * 3. CMDParser.parseDownlink:合法 + 闭合结构(拒绝额外字段)
8
+ * 4. toMcpTools:agentCallable=true 的 downlink 即工具集,description 非空
9
+ * 5. GameSession.execDownlink:Agent 可调用指令注入成功,会话生命周期指令被 FORBIDDEN 拦截
10
+ */
11
+ import {
12
+ assertProtocolSync,
13
+ CMDParser,
14
+ PROTOCOL,
15
+ toMcpTools,
16
+ } from '../shared/game-protocol'
17
+ import { gameSession } from '../server/services/game/session'
18
+ import type { ServerToClient } from '../server/types/game'
19
+
20
+ let failures = 0
21
+ function check(name: string, ok: boolean, detail = ''): void {
22
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
23
+ if (!ok) {
24
+ failures += 1
25
+ }
26
+ }
27
+
28
+ /** 假 WS peer:捕获下行指令(结构兼容 h3 peer) */
29
+ class FakePeer {
30
+ sent: ServerToClient[] = []
31
+
32
+ send(data: string | Uint8Array): void {
33
+ this.sent.push(JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data)))
34
+ }
35
+
36
+ close(): void {}
37
+ }
38
+
39
+ function testProtocolSync(): void {
40
+ console.log('\n--- 1. 协议同步 ---')
41
+ const report = assertProtocolSync()
42
+ check('downlink 键集合无漂移', report.unsyncedDownlink.length === 0, JSON.stringify(report.downlink))
43
+ check('uplink 键集合无漂移', report.unsyncedUplink.length === 0, JSON.stringify(report.uplink))
44
+ check('所有 example 自洽', report.badExamples.length === 0)
45
+ check('agentCallable 指令存在', report.downlink.some(k => PROTOCOL.downlink[k]?.agentCallable))
46
+ }
47
+
48
+ function testParseUplink(): void {
49
+ console.log('\n--- 2. CMDParser.parseUplink ---')
50
+
51
+ // 合法
52
+ const ok = CMDParser.parseUplink('{"type":"input.move","payload":{"dx":1,"dy":0}}')
53
+ check('合法 input.move 解析成功', ok.ok && ok.value.type === 'input.move')
54
+
55
+ // 空字符串
56
+ const empty = CMDParser.parseUplink('')
57
+ check('空字符串 → BAD_MESSAGE', !empty.ok && empty.error.code === 'BAD_MESSAGE')
58
+
59
+ // 坏 JSON
60
+ const bad = CMDParser.parseUplink('{not json')
61
+ check('坏 JSON → BAD_MESSAGE', !bad.ok && bad.error.code === 'BAD_MESSAGE')
62
+
63
+ // 缺 payload
64
+ const noPayload = CMDParser.parseUplink('{"type":"input.move","payload_dx":1}')
65
+ check('缺 payload → BAD_MESSAGE', !noPayload.ok && noPayload.error.code === 'BAD_MESSAGE')
66
+
67
+ // 未知指令
68
+ const unknown = CMDParser.parseUplink('{"type":"foo.bar","payload":{}}')
69
+ check('未知指令 → UNKNOWN_COMMAND', !unknown.ok && unknown.error.code === 'UNKNOWN_COMMAND')
70
+
71
+ // 无效枚举
72
+ const badEnum = CMDParser.parseUplink('{"type":"input.move","payload":{"dx":2,"dy":0}}')
73
+ check('dx=2 越界枚举 → INVALID_PAYLOAD', !badEnum.ok && badEnum.error.code === 'INVALID_PAYLOAD',
74
+ badEnum.ok ? '' : badEnum.error.path)
75
+
76
+ // 缺必填
77
+ const missing = CMDParser.parseUplink('{"type":"player.pos","payload":{"x":1,"y":2}}')
78
+ check('缺 tileX/tileY → INVALID_PAYLOAD', !missing.ok && missing.error.code === 'INVALID_PAYLOAD')
79
+ }
80
+
81
+ function testParseDownlink(): void {
82
+ console.log('\n--- 3. CMDParser.parseDownlink ---')
83
+
84
+ const ok = CMDParser.parseDownlink('{"type":"agent.say","payload":{"text":"hi","ttlMs":1000}}')
85
+ check('合法 agent.say 解析成功', ok.ok && ok.value.type === 'agent.say')
86
+
87
+ // 闭合结构:拒绝额外字段
88
+ const extra = CMDParser.parseDownlink('{"type":"agent.face","payload":{"dir":"down","extra":1}}')
89
+ check('额外字段被拒绝(闭合 schema)', !extra.ok && extra.error.code === 'INVALID_PAYLOAD')
90
+
91
+ // 对象类型指令:缺必填
92
+ const missing = CMDParser.parseDownlink('{"type":"session.ready","payload":{"agentName":"x"}}')
93
+ check('session.ready 缺 spawn → INVALID_PAYLOAD', !missing.ok && missing.error.code === 'INVALID_PAYLOAD')
94
+ }
95
+
96
+ function testMcpTools(): void {
97
+ console.log('\n--- 4. MCP 工具词表 ---')
98
+ const tools = toMcpTools()
99
+ const names = tools.map(t => t.name)
100
+ check('工具数 > 0', tools.length > 0, names.join(','))
101
+
102
+ const allCallable = names.every(n => PROTOCOL.downlink[n]?.agentCallable === true)
103
+ check('全部工具对应 agentCallable=true', allCallable)
104
+
105
+ const allDescribed = tools.every(t => t.description.length > 0 && t.inputSchema.type === 'object')
106
+ check('每个工具有 description + object inputSchema', allDescribed)
107
+
108
+ // 关键 Agent 工具应在册
109
+ check('agent.move 在工具集', names.includes('agent.move'))
110
+ check('agent.say 在工具集', names.includes('agent.say'))
111
+ check('dialog.open 在工具集', names.includes('dialog.open'))
112
+
113
+ // 非工具:会话生命周期 + 协议内部指令不应可调用
114
+ check('session.ready 不在工具集', !names.includes('session.ready'))
115
+ check('dialog.advance 不在工具集', !names.includes('dialog.advance'))
116
+ check('error 不在工具集', !names.includes('error'))
117
+ }
118
+
119
+ async function testExecDownlink(): Promise<void> {
120
+ console.log('\n--- 5. GameSession.execDownlink(MCP 接缝) ---')
121
+ const peer = new FakePeer()
122
+ gameSession.connect(peer)
123
+ // connect 后首两条为 session.ready / agent.state,execDownlink 的新指令在其后追加
124
+ const baseline = peer.sent.length
125
+
126
+ // Agent 可调用指令:注入成功 + 下行广播
127
+ const res = gameSession.execDownlink('agent.say', { text: '来自 Agent', ttlMs: 1500 })
128
+ check('execDownlink agent.say 成功', res.ok)
129
+ check('agent.say 下发到 peer', peer.sent.length === baseline + 1
130
+ && peer.sent[peer.sent.length - 1]!.type === 'agent.say', `sent=${peer.sent.length}`)
131
+
132
+ // 非 agentCallable 指令:FORBIDDEN
133
+ const forbidden = gameSession.execDownlink('session.ready', { agentName: 'x', spawn: { x: 0, y: 0 } })
134
+ check('session.ready 被 FORBIDDEN 拦截', !forbidden.ok && forbidden.error.code === 'FORBIDDEN')
135
+
136
+ // 无效 payload:校验失败
137
+ const invalid = gameSession.execDownlink('agent.move', { dir: 'sideways', durationMs: 100 })
138
+ check('agent.move 非法 dir → 校验失败', !invalid.ok && invalid.error.code === 'INVALID_PAYLOAD')
139
+ gameSession.stop()
140
+ }
141
+
142
+ async function main(): Promise<void> {
143
+ testProtocolSync()
144
+ testParseUplink()
145
+ testParseDownlink()
146
+ testMcpTools()
147
+ await testExecDownlink()
148
+
149
+ console.log(`\n${failures === 0 ? 'ALL PASS' : `${failures} FAILED`}`)
150
+ process.exit(failures === 0 ? 0 : 1)
151
+ }
152
+
153
+ main().catch((e) => {
154
+ console.error(e)
155
+ process.exit(1)
156
+ })
@@ -0,0 +1,72 @@
1
+ import { openWorkshopDb } from '../server/services/workshop/db/database'
2
+ import { createTaskRepo } from '../server/services/workshop/db/task.repo'
3
+ import { TaskEngine } from '../server/services/workshop/runtime/task-engine'
4
+ import { createMessageRepo } from '../server/services/workshop/db/message.repo'
5
+
6
+ const db = openWorkshopDb(':memory:')
7
+ const now = new Date().toISOString()
8
+ db.prepare('INSERT INTO channels (id, name, enabled, created_at, updated_at) VALUES (\'ch1\', \'test\', 1, ?, ?)').run(now, now)
9
+ const tasks = createTaskRepo(db)
10
+ const messages = createMessageRepo(db)
11
+ const engine = new TaskEngine({ tasks, messages })
12
+
13
+ let failed = 0
14
+ const okIf = (m: string, c: boolean) => {
15
+ if (c) console.log(`PASS ${m}`)
16
+ else {
17
+ console.log(`FAIL ${m}`)
18
+ failed++
19
+ }
20
+ }
21
+
22
+ const parent = engine.create({ channelId: 'ch1', creatorId: 'u1', assigneeId: 'lead', title: 'parent', description: '[mode:goal] do it' })
23
+ // 调度器同款前置:SUBMITTED → WORKING 后 dispatch(WORKING → WAITING 合法)
24
+ engine.transition(parent.id, 'WORKING', 'lead')
25
+ engine.dispatch(parent, { assigneeId: 'w1', title: 'child one' })
26
+
27
+ // 判重:同父 + 归一化同题(大小写/空白)+ 非终态 → 409,不创建新任务
28
+ let caught409 = false
29
+ try {
30
+ engine.dispatch(parent, { assigneeId: 'w2', title: 'Child ONE' })
31
+ }
32
+ catch (e) {
33
+ const err = e as { code?: string, statusCode?: number, status?: number }
34
+ caught409 = err.code === 'DUPLICATE_DISPATCH' || err.statusCode === 409 || err.status === 409
35
+ }
36
+ okIf('duplicate-dispatch-409', caught409)
37
+
38
+ // 409 后无新任务:parent + child one = 2 行;children = 1
39
+ const lite = engine.listLite('ch1')
40
+ const views = engine.queueViewsOfLite('ch1')
41
+ okIf(`listLite rows after 409: ${lite.length} (expect 2: parent+child one)`, lite.length === 2)
42
+ okIf('lite fields ok(状态/进度/数组字段齐备,大列置空)', lite.every(t => typeof t.id === 'string' && t.state && typeof t.progress === 'number' && Array.isArray(t.history) && Array.isArray(t.artifacts)))
43
+ okIf(`queueViewsLite assignees: ${[...views.keys()].sort().join(',')} (expect lead,w1)`, [...views.keys()].sort().join(',') === 'lead,w1')
44
+ okIf(`queued in w1 view: ${views.get('w1')?.queued.length ?? -1} (expect 1)`, views.get('w1')?.queued.length === 1)
45
+ okIf(`listChildrenMeta after 409: ${tasks.listChildrenMeta('ch1', parent.id).length} (expect 1)`, tasks.listChildrenMeta('ch1', parent.id).length === 1)
46
+
47
+ // 异题派发合法:第二个子任务可建
48
+ const c3 = engine.dispatch(parent, { assigneeId: 'w2', title: 'child two' })
49
+ okIf('second-distinct-child', !!c3)
50
+ okIf(`listLite rows after child two: ${engine.listLite('ch1').length} (expect 3)`, engine.listLite('ch1').length === 3)
51
+ okIf(`listChildrenMeta after child two: ${tasks.listChildrenMeta('ch1', parent.id).length} (expect 2)`, tasks.listChildrenMeta('ch1', parent.id).length === 2)
52
+
53
+ // 完成闸门:仅一个子完成 → parent 仍 WAITING;双子终态后显式 complete(parent) 放行
54
+ const childrenIds = tasks.listChildrenMeta('ch1', parent.id).map(r => r.id)
55
+ engine.transition(childrenIds[0]!, 'WORKING', 'w1')
56
+ engine.complete(childrenIds[0]!, [{ artifactId: 'a1', name: 'output', parts: [{ text: 'done' }] }])
57
+ okIf(`complete-child: ${engine.get(childrenIds[0]!)?.state} (expect COMPLETED)`, engine.get(childrenIds[0]!)?.state === 'COMPLETED')
58
+ okIf(`parent state(单子完成): ${engine.get(parent.id)?.state} (expect WAITING)`, engine.get(parent.id)?.state === 'WAITING')
59
+ engine.transition(childrenIds[1]!, 'WORKING', 'w2')
60
+ engine.complete(childrenIds[1]!, [{ artifactId: 'a2', name: 'output2', parts: [{ text: 'done2' }] }])
61
+ // 父任务收口由调度器(goal 宽限窗/规则引擎)负责;TaskEngine 层验证 done-check 闸门:
62
+ // 全部子任务终态后 complete(parent) 应放行(WAITING → COMPLETED 合法迁移)
63
+ engine.complete(parent.id, [{ artifactId: 'a3', name: 'summary', parts: [{ text: 'all done' }] }])
64
+ okIf(`parent state(双子终态后显式 complete): ${engine.get(parent.id)?.state} (expect COMPLETED)`, engine.get(parent.id)?.state === 'COMPLETED')
65
+
66
+ // F1:legacy 迁移列与索引
67
+ const cols = (db.prepare('PRAGMA table_info(tasks)').all() as Array<{ name: string }>).map(c => c.name)
68
+ okIf('route_reason column present', cols.includes('route_reason'))
69
+ okIf('idx_tasks_parent present', !!db.prepare('SELECT name FROM sqlite_master WHERE type=\'index\' AND name=\'idx_tasks_parent\'').get())
70
+
71
+ console.log(failed ? `SMOKE FAILED(${failed})` : 'SMOKE ALL PASS')
72
+ process.exit(failed ? 1 : 0)