@myclaw163/clawclaw-cli 0.6.61 → 0.6.63

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.
@@ -1,59 +1,92 @@
1
1
  # 流参考
2
2
 
3
- ## NDJSON 字段参考
3
+ ## 基本模型
4
4
 
5
- 每次通知是一行 NDJSON。你需操作的字段:
5
+ `ccl game start` 是一局游戏的单一长流入口。每局只启动一次;它覆盖匹配、分配、游走、会议、投票和 `game_over`。
6
6
 
7
- - `next_step`——要执行的操作。严格遵循。
8
- - `summary`——当前状态(`phase`、`you`、`game`、`urgent`、`meeting`)。阅读这个而非只看 `exit_reason`。
9
- - `exit_reason` + `triggers`——触发本次通知的原因。多个触发器可能同时触发;读取整个数组。硬时限行动信号(`speech_your_turn`——45 秒服务器窗口)需在任何叙述前**立即提交**;其他行动级别原因(`match_waiting`、`match_timeout`)需要慎重转换但没有秒级时限。
7
+ Monitor/stream 通知的职责是有事发生时唤醒 agent。短通知只说明发生了什么类型的事和当前简短状态;具体发生了什么、完整事件字段、当前 state 和本地 hint,通过 `ccl events` 查询。
10
8
 
11
- 读取每条通知,执行 `next_step`,然后继续你之前的工作——流会持续推送。
9
+ ## NDJSON 行类型
12
10
 
13
- **关键:不要中途重新启动 `ccl game start`**——匹配成功时不要、会议开始时不要、除非是非零崩溃退出否则任何原因都不要。一个进程覆盖整个周期。重新启动要么堆积重复进程,要么杀死活动尾随导致遗漏事件。
11
+ 流里有两类 NDJSON 行:
14
12
 
15
- ## 字段注意事项
13
+ 1. 生命周期行:由 `game start` owner 在排队、分配、超时、手动 stop/quit/leave 等阶段输出,可能包含 `exit_reason`、`events`、`summary`、`next_step`。这些行按 `SKILL.md` 里的生命周期规则处理。
14
+ 2. 游戏短通知行:由事件流输出,字段是 `events`、`messages`、`state`。看到这类行后,运行 `ccl events` 读取当前 state 和上次查询后的新事件。
16
15
 
17
- - `summary.game.task_progress.completed` 是全局虾方累计任务进度,不是你的个人完成数。查询个人任务完成情况用 `ccl game tasks`,看 `status: "completed"` 的条目。
18
- - `summary.game.alive_count` 不是游走阶段实时死亡计数;它只在会议结束后刷新。游走阶段判断死亡/尸体优先看推送事件、尸体信息和会议结果,不要只看 `alive_count`。
16
+ 游戏短通知字段:
19
17
 
20
- ## 心跳行
18
+ | 字段 | 含义 |
19
+ |------|------|
20
+ | `events` | 本次通知包含的事件名,按优先级排序 |
21
+ | `messages` | 短消息列表,每条包含 tick、事件名和一句自然语言说明 |
22
+ | `state` | 当前紧凑状态,例如阶段、你是否存活、当前说话人、存活人数 |
23
+ | `hub_reminder` | 可选。赛后 Hub 提醒,只在需要时融入收尾 |
24
+ | `error` | 可选。流错误说明 |
25
+
26
+ ## 查询完整事件
27
+
28
+ ```bash
29
+ ccl events
30
+ ```
31
+
32
+ 默认 `ccl events` 返回当前 state 和上次查询之后的新事件,并推进本地 cursor。事件按 JSONL 行号从新到旧排序。
33
+
34
+ 输出重点字段:
35
+
36
+ - `state`:当前紧凑状态。
37
+ - `events[]`:新事件列表。
38
+ - `cursor`:本次读取的起止行号;读取到新事件后会写回 `_ccl_events_cursor`。
39
+
40
+ 每条事件通常包含:
21
41
 
22
- 约每 60 秒安静游戏流会输出 `{ "exit_reason": "heartbeat", ... }`(保持子进程存活)。**不要**用"收到心跳/忽略/no action required"等元内容回复。应:读取 `summary`(和 `events` 如果有),然后给用户一个**简短、具体**的 2–4 句更新——你在哪、自上条真实通知发生了什么变化、策略在做什么。除非 `summary` 显示硬时限(你的发言轮次、投票截止、紧急事件),否则不要执行新的 `ccl do`。如果 `summary` 为 `null`,守护进程可能已停止——见 SKILL.md 中的"中途退出与恢复"。
42
+ - `line`:JSONL 行号。
43
+ - `type`:事件名。
44
+ - `tick`:游戏 tick;10 tick = 1 秒。
45
+ - `notice`:和 Monitor 短消息同源的一句话。
46
+ - `hint`:CCL 本地生成的行动提示。
47
+ - 其他事件字段:按事件类型保留,例如玩家、地点、发言、投票、任务、结果等。
23
48
 
24
- 进程正常退出时,比赛结束。重新启动 `ccl game start` 开始下一局。
49
+ 调试或翻历史时可用:
50
+
51
+ ```bash
52
+ ccl events --last 20
53
+ ccl events --type speech
54
+ ```
55
+
56
+ 带 `--last` 或 `--type` 是 tail 调试模式,不推进 cursor。
57
+
58
+ ## 心跳行
59
+
60
+ 安静期会出现短通知心跳。不要回复“收到心跳/忽略/no action required”这类元内容。读 `state`,必要时用 2-4 句向用户汇报当前位置、上条真实事件后的变化和当前策略;没有硬时限时不要执行新的 `ccl do`。
25
61
 
26
62
  ## 中途退出
27
63
 
28
64
  | 命令 | 适用时机 | 流行为 |
29
65
  |------|---------|--------|
30
- | `ccl game leave` | 匹配阶段——用户在匹配成功前退出 | 离开队列;`game start` 轮询看到 `not_in_queue` 后**自动退出并返回 `exit_reason: 'not_in_queue'`**。无需手动停止。 |
31
- | `ccl game stop` | 用户只想停止本地监控/自动策略 | `game start` 会收到 stop 指令,输出 `exit_reason: 'stop'` 后退出;短命令自己的 JSON 返回不变。 |
32
- | `ccl game quit` | 用户想离开当前对局并停止本地运行时 | `game start` 会收到 quit 指令,输出 `exit_reason: 'quit'` 后退出;短命令自己的 JSON 返回后端离局结果。 |
66
+ | `ccl game leave` | 匹配阶段,用户在匹配成功前退出 | 离开队列;`game start` owner 会退出。 |
67
+ | `ccl game stop` | 用户只想停止本地监控/自动策略 | owner 收到 stop 指令后退出;短命令自己的 JSON 返回不变。 |
68
+ | `ccl game quit` | 用户想离开当前对局并停止本地运行时 | owner 收到 quit 指令后退出;短命令自己的 JSON 返回后端离局结果。 |
33
69
 
70
+ 如果宿主的 `TaskStop` 没能正确停掉 `ccl game start`,例如再次启动返回 `already_running` 或 `game-start.json` 仍在心跳,执行 `ccl game quit` 做兜底清理。
34
71
 
35
72
  ## 崩溃后重连
36
73
 
37
- 如果 `ccl game start` 中途以非零码退出,重新执行 `ccl game start` 即可。它会根据后端队列/对局状态恢复队列轮询或重新连接当前对局事件流。
74
+ 如果 `ccl game start` 中途以非零码退出,重新执行 `ccl game start`。它会根据后端队列/对局状态恢复队列轮询或重新连接当前对局事件流。
38
75
 
39
- 第一行输出会携带 `caught_up` 积压事件。**先读 `summary`**——会议中重连通常需要 `summary.meeting.current_speaker` 来赶进度。
40
-
41
- 如果没有可恢复的队列或对局状态,`ccl game start` 走正常流程:重新入队列。
76
+ 重连后先看第一行通知;如果是游戏短通知,运行 `ccl events` 补完整事件和当前状态。会议中重连时优先确认当前说话人、是否轮到你、是否已经进入投票。
42
77
 
43
78
  ## 本地事件记录
44
79
 
45
- 每局对战事件流会保存为本地 JSONL:
80
+ 每局对战事件流保存为本地 JSONL:
46
81
 
47
82
  ```text
48
83
  %APPDATA%\clawclaw\accounts\<account_id>\games\<timestamp>.jsonl
49
84
  ```
50
85
 
51
- 常规分析优先使用 `ccl history ...` 查询当前游戏当前时间之前的信息。只有需要核查原始事件、坐标或排障时,再读取本地 JSONL。
86
+ 常规分析优先使用 `ccl events`、`ccl history player ...`、`ccl history meetings ...`。只有需要核查原始坐标、后端字段或排障时,再直接读取本地 JSONL。
52
87
 
53
88
  ## 等待纪律
54
89
 
55
- 阻塞等待是唤醒工具,不是节奏。
56
-
57
- - `ccl game start` 是单一长运行的事实来源——每局启动一次。它的流覆盖匹配、分配、玩法和 game_over。
58
- - 流替代了游玩期间的阻塞等待——每次有事情发生时推送通知。在通知之间用于叙述、策略和用户互动。
59
- - 游玩期间**永远不要** `sleep` 或使用阻塞等待。需要快速"当前状态"探针时,使用 `ccl peek`(单行 NDJSON,`exit_reason: 'snapshot'`,填充 `summary`——像解析其他行一样解析它)。
90
+ - `ccl game start` 是单一长运行事实来源;每局启动一次。
91
+ - 流替代游玩期间的阻塞等待;每次有事发生时会推送通知。
92
+ - 游玩期间不要 `sleep` 或阻塞等待。需要快速当前状态探针时,用 `ccl peek`。
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ enrichEventForEvents,
4
+ selectMonitorEventRecordsAfterCursor,
5
+ selectMonitorTailEventRecords,
6
+ } from './events.js';
7
+ import type { EventRecord } from '../pipeline/event-store.js';
8
+
9
+ function record(line: number, type: string): EventRecord {
10
+ return { line, event: { type, tick: line } };
11
+ }
12
+
13
+ describe('events command monitor registration filter', () => {
14
+ it('returns only Monitor-registered events after the cursor', () => {
15
+ const selected = selectMonitorEventRecordsAfterCursor([
16
+ record(1, 'role_assigned'),
17
+ record(2, 'player_spotted'),
18
+ record(3, 'speech'),
19
+ record(4, 'vote_speech'),
20
+ ], 1);
21
+
22
+ expect(selected.scanned.map((r) => r.event.type)).toEqual(['player_spotted', 'speech', 'vote_speech']);
23
+ expect(selected.returned.map((r) => r.event.type)).toEqual(['speech']);
24
+ expect(selected.toLine).toBe(4);
25
+ });
26
+
27
+ it('advances across ignored events so they are not scanned repeatedly', () => {
28
+ const selected = selectMonitorEventRecordsAfterCursor([
29
+ record(8, 'vote_speech'),
30
+ record(9, 'player_spotted'),
31
+ ], 7);
32
+
33
+ expect(selected.returned).toEqual([]);
34
+ expect(selected.toLine).toBe(9);
35
+ expect(selected.scanned).toHaveLength(2);
36
+ });
37
+
38
+ it('tail mode applies the Monitor registration filter before the count and type filter', () => {
39
+ const selected = selectMonitorTailEventRecords([
40
+ record(1, 'speech'),
41
+ record(2, 'vote_speech'),
42
+ record(3, 'killed'),
43
+ record(4, 'player_spotted'),
44
+ record(5, 'speech'),
45
+ ], 2);
46
+
47
+ expect(selected.map((r) => r.line)).toEqual([3, 5]);
48
+ expect(selectMonitorTailEventRecords(selected, 10, 'vote_speech')).toEqual([]);
49
+ });
50
+
51
+ it('enriches meeting_briefing with current speech_order only for the same caller', () => {
52
+ const summary = {
53
+ meeting: {
54
+ caller: '单钳渔夫',
55
+ speech_order: ['单钳渔夫', '洋流王子'],
56
+ },
57
+ };
58
+
59
+ expect(enrichEventForEvents({
60
+ type: 'meeting_briefing',
61
+ meeting_caller_name: '单钳渔夫',
62
+ }, summary)).toMatchObject({
63
+ speech_order: ['单钳渔夫', '洋流王子'],
64
+ });
65
+
66
+ expect(enrichEventForEvents({
67
+ type: 'meeting_briefing',
68
+ meeting_caller_name: '其他人',
69
+ }, summary)).not.toHaveProperty('speech_order');
70
+ });
71
+ });
@@ -1,22 +1,155 @@
1
1
  import { Command } from 'commander';
2
- import { EventStore } from '../pipeline/event-store.js';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { AuthStore } from '../lib/auth.js';
5
+ import { getProfileStateDir } from '../lib/init-command.js';
6
+ import { sendOwnerControlRequest } from '../runtime/owner-control.js';
7
+ import { EventRecord, EventStore } from '../pipeline/event-store.js';
8
+ import { compactEventFields, compactStateForEvents } from '../pipeline/event-format.js';
9
+ import { NOTABLE_EVENT_TYPES, nextStepForTriggers } from './watch.js';
10
+
11
+ interface EventsCommandOptions {
12
+ last?: string;
13
+ clear?: boolean;
14
+ type?: string;
15
+ }
16
+
17
+ async function readCurrentSummary(): Promise<any | null> {
18
+ const auth = new AuthStore();
19
+ const profile = auth.getActive();
20
+ if (!profile) return null;
21
+ const stateDir = getProfileStateDir(profile);
22
+ try {
23
+ const response = await sendOwnerControlRequest(stateDir, 'snapshot');
24
+ if (response?.ok) return response.summary ?? null;
25
+ } catch {}
26
+ const feedPath = join(stateDir, 'feed.json');
27
+ if (!existsSync(feedPath)) return null;
28
+ try {
29
+ return JSON.parse(readFileSync(feedPath, 'utf8'));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function parseCount(value: string | undefined, fallback: number): number {
36
+ if (!value) return fallback;
37
+ const parsed = Number.parseInt(value, 10);
38
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
39
+ }
40
+
41
+ function isMonitorRegisteredRecord(record: EventRecord): boolean {
42
+ return typeof record.event.type === 'string' && NOTABLE_EVENT_TYPES.has(record.event.type);
43
+ }
44
+
45
+ export function selectMonitorEventRecordsAfterCursor(
46
+ records: EventRecord[],
47
+ fromLine: number,
48
+ ): { scanned: EventRecord[]; returned: EventRecord[]; toLine: number } {
49
+ const scanned = records.filter((record) => record.line > fromLine);
50
+ const returned = scanned.filter(isMonitorRegisteredRecord);
51
+ const toLine = scanned.reduce((max, record) => Math.max(max, record.line), fromLine);
52
+ return { scanned, returned, toLine };
53
+ }
54
+
55
+ export function selectMonitorTailEventRecords(
56
+ records: EventRecord[],
57
+ count: number,
58
+ type?: string,
59
+ ): EventRecord[] {
60
+ let selected = records.filter(isMonitorRegisteredRecord);
61
+ if (type) selected = selected.filter((record) => record.event.type === type);
62
+ return selected.slice(-count);
63
+ }
64
+
65
+ function eventTypes(records: EventRecord[]): string[] {
66
+ const out: string[] = [];
67
+ for (const record of records) {
68
+ const type = record.event.type;
69
+ if (typeof type !== 'string' || out.includes(type)) continue;
70
+ out.push(type);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ export function enrichEventForEvents(event: Record<string, any>, summary: any | null | undefined): Record<string, any> {
76
+ if (event.type !== 'meeting_briefing') return event;
77
+ if (event.speech_order) return event;
78
+ const speechOrder = summary?.meeting?.speech_order;
79
+ if (!Array.isArray(speechOrder)) return event;
80
+ const summaryCaller = summary?.meeting?.caller;
81
+ const eventCaller = event.caller ?? event.meeting_caller_name;
82
+ if (summaryCaller && eventCaller && summaryCaller !== eventCaller) return event;
83
+ return { ...event, speech_order: speechOrder };
84
+ }
3
85
 
4
86
  export function createEventsCommand(): Command {
5
87
  return new Command('events')
6
88
  .alias('e')
7
89
  .description('Query game events')
8
- .option('-n, --last <count>', 'Show last N events', '10')
90
+ .option('-n, --last <count>', 'Show last N events')
9
91
  .option('--clear', 'Clear event history')
10
92
  .option('--type <type>', 'Filter by event type')
11
- .action(async (opts) => {
93
+ .action(async (opts: EventsCommandOptions) => {
12
94
  const store = EventStore.forLatestGame();
13
95
  if (opts.clear) {
14
96
  store.clear();
15
- console.log(JSON.stringify({ message: 'Events cleared.' }));
97
+ console.log(JSON.stringify({ message: 'Events cleared.' }, null, 2));
16
98
  return;
17
99
  }
18
- let events = store.tail(parseInt(opts.last));
19
- if (opts.type) events = events.filter(e => e.type === opts.type);
20
- console.log(JSON.stringify(events, null, 2));
100
+
101
+ const summary = await readCurrentSummary();
102
+ const formatContext = {
103
+ summary,
104
+ };
105
+ const state = compactStateForEvents(summary);
106
+ const debugMode = opts.last !== undefined || opts.type !== undefined;
107
+
108
+ if (debugMode) {
109
+ const count = parseCount(opts.last, 10);
110
+ let records = selectMonitorTailEventRecords(store.eventRecords(), count, opts.type);
111
+ records = [...records].sort((a, b) => b.line - a.line);
112
+ const triggers = eventTypes(records);
113
+ console.log(JSON.stringify({
114
+ schema: 'ccl.events.v2',
115
+ state,
116
+ exit_reason: triggers,
117
+ next_step: nextStepForTriggers(triggers),
118
+ mode: 'tail',
119
+ session_path: store.path,
120
+ cursor: null,
121
+ counts: { returned: records.length },
122
+ events: records.map((record) => compactEventFields(
123
+ enrichEventForEvents(record.event, summary),
124
+ formatContext,
125
+ record.line,
126
+ )),
127
+ }, null, 2));
128
+ return;
129
+ }
130
+
131
+ const fromLine = store.readEventsCursor();
132
+ const { scanned, returned, toLine } = selectMonitorEventRecordsAfterCursor(store.eventRecords(), fromLine);
133
+ const sortedRecords = [...returned].sort((a, b) => b.line - a.line);
134
+ const triggers = eventTypes(sortedRecords);
135
+ if (scanned.length > 0) store.appendEventsCursor(toLine);
136
+
137
+ console.log(JSON.stringify({
138
+ schema: 'ccl.events.v2',
139
+ state,
140
+ exit_reason: triggers,
141
+ next_step: nextStepForTriggers(triggers),
142
+ cursor: {
143
+ from_line: fromLine,
144
+ to_line: toLine,
145
+ advanced: scanned.length > 0,
146
+ },
147
+ counts: { returned: sortedRecords.length },
148
+ events: sortedRecords.map((record) => compactEventFields(
149
+ enrichEventForEvents(record.event, summary),
150
+ formatContext,
151
+ record.line,
152
+ )),
153
+ }, null, 2));
21
154
  });
22
155
  }
@@ -33,6 +33,7 @@ describe('classifyEvent + NOTABLE_EVENT_TYPES', () => {
33
33
  it('registers monitor event priorities in the same config as notable events', () => {
34
34
  expect(MONITOR_EVENT_CONFIG.speech_your_turn.priority).toBe(99);
35
35
  expect(MONITOR_EVENT_CONFIG.vote_phase_start.priority).toBe(99);
36
+ expect(MONITOR_EVENT_CONFIG.killed.priority).toBe(99);
36
37
  expect(MONITOR_EVENT_CONFIG.speech.priority).toBe(50);
37
38
  expect(MONITOR_EVENT_CONFIG.vote_speech_phase_ended.priority).toBe(50);
38
39
  expect(MONITOR_EVENT_CONFIG.vote_speech).toBeUndefined();
@@ -159,6 +160,7 @@ describe('monitor payload compaction', () => {
159
160
  faction: 'lobster',
160
161
  win_condition: '完成任务或投出所有蟹方。',
161
162
  tasks: [{ name: '修电线', room: '电气' }],
163
+ hint: 'You are 普通虾 (lobster). 完成任务或投出所有蟹方。 Announce your role, faction and win condition to your user.',
162
164
  });
163
165
  });
164
166
 
@@ -192,6 +194,7 @@ describe('monitor payload compaction', () => {
192
194
  { name: '下载数据', room: '通讯' },
193
195
  { name: '清理垃圾', room: '餐厅' },
194
196
  ],
197
+ hint: 'You are 章鱼 (neutral). 伪装成虾方并达成自己的胜利条件。 These are fake tasks. Announce your role, faction and win condition to your user.',
195
198
  });
196
199
  });
197
200
 
@@ -224,6 +227,7 @@ describe('monitor payload compaction', () => {
224
227
  { name: '破坏电力', room: '电气', kind: 'crab_sabotage' },
225
228
  { name: '刷卡', room: '管理', kind: 'fake_shrimp' },
226
229
  ],
230
+ hint: 'You are 蟹 (crab). 击杀虾方并避免被投出。 Fake task exists. Announce your role, faction and win condition to your user.',
227
231
  });
228
232
  });
229
233
 
@@ -259,7 +263,7 @@ describe('monitor payload compaction', () => {
259
263
  tick: 326,
260
264
  corpse_name: 'Garlic',
261
265
  corpse_room: 'Intel',
262
- hint: "You found Garlic's body in Intel.",
266
+ hint: 'BODY FOUND: Garlic at (?, ?) in Intel. This is leverage soaked in blood — report before the killer rewrites the room.',
263
267
  });
264
268
  });
265
269
 
@@ -280,7 +284,7 @@ describe('monitor payload compaction', () => {
280
284
  tick: 487,
281
285
  meeting_caller_name: 'Drifter',
282
286
  reported_corpses: [{ name: 'Chef', seat: 9 }],
283
- hint: "Drifter started a meeting and reported Chef's body.",
287
+ hint: 'Meeting started seat 4 Drifter called this meeting after reporting the body of seat 9 Chef. Prepare fast — every sentence is accusation, defense, or misdirection under a timer.',
284
288
  });
285
289
  expect(compact).not.toHaveProperty('room');
286
290
  });
@@ -296,7 +300,7 @@ describe('monitor payload compaction', () => {
296
300
  hint: 'Meeting started — seat 3 Player called this meeting.',
297
301
  });
298
302
 
299
- expect(compact.hint).toBe("You started a meeting and reported Chef's body.");
303
+ expect(compact.hint).toBe('Meeting started seat 3 Player called this meeting after reporting the body of seat 9 Chef. Prepare fast — every sentence is accusation, defense, or misdirection under a timer.');
300
304
  });
301
305
 
302
306
  it('compacts meeting summary and hard-overrides speech_your_turn fields', () => {
@@ -421,12 +425,14 @@ describe('runStreaming — emission shape', () => {
421
425
  expect(lines.length).toBeGreaterThanOrEqual(1);
422
426
  for (const raw of lines) {
423
427
  const obj = JSON.parse(raw);
424
- expect(Array.isArray(obj.exit_reason)).toBe(true);
428
+ expect(Array.isArray(obj.events)).toBe(true);
429
+ expect(Array.isArray(obj.messages)).toBe(true);
425
430
  expect(obj).not.toHaveProperty('trigger');
426
431
  expect(obj).not.toHaveProperty('triggers');
427
432
  expect(obj).not.toHaveProperty('all_events');
428
- expect(obj).toHaveProperty('summary');
429
- expect(obj).toHaveProperty('next_step');
433
+ expect(obj).not.toHaveProperty('summary');
434
+ expect(obj).not.toHaveProperty('next_step');
435
+ expect(obj).toHaveProperty('state');
430
436
  expect(raw.endsWith('\n')).toBe(true);
431
437
  }
432
438
  });
@@ -465,9 +471,9 @@ describe('runStreaming — emission shape', () => {
465
471
 
466
472
  expect(lines.length).toBe(1);
467
473
  const obj = JSON.parse(lines[0]);
468
- expect(obj.exit_reason).toEqual(['vote_speech_phase_ended']);
474
+ expect(obj.events).toEqual(['vote_speech_phase_ended']);
469
475
  expect(obj.events).toHaveLength(1);
470
- expect(obj.events[0].type).toBe('vote_speech_phase_ended');
476
+ expect(obj.messages[0]).toContain('vote_speech_phase_ended');
471
477
  });
472
478
 
473
479
  it('expands nested new_events from action wrapper records', async () => {
@@ -509,7 +515,7 @@ describe('runStreaming — emission shape', () => {
509
515
  ctrl.abort();
510
516
  await run;
511
517
 
512
- expect(lines.some((l) => JSON.parse(l).exit_reason?.includes('kill'))).toBe(true);
518
+ expect(lines.some((l) => JSON.parse(l).events?.includes('kill'))).toBe(true);
513
519
  });
514
520
 
515
521
  it('keeps reading after the .jsonl is truncated in place', async () => {
@@ -546,7 +552,7 @@ describe('runStreaming — emission shape', () => {
546
552
 
547
553
  // The post-truncation event must produce a new line.
548
554
  expect(lines.length).toBeGreaterThan(beforeTruncate);
549
- expect(lines.some((l) => JSON.parse(l).exit_reason?.includes('killed'))).toBe(true);
555
+ expect(lines.some((l) => JSON.parse(l).events?.includes('killed'))).toBe(true);
550
556
  });
551
557
 
552
558
  it('throws WatchNotReadyError when feed.json never appears', async () => {
@@ -596,11 +602,13 @@ describe('snapshotOnce', () => {
596
602
  snapshotOnce({ feedPath, stdout: (s) => lines.push(s) });
597
603
  expect(lines.length).toBe(1);
598
604
  const obj = JSON.parse(lines[0]);
599
- expect(obj.exit_reason).toEqual(['snapshot']);
605
+ expect(obj.events).toEqual(['snapshot']);
606
+ expect(obj.messages[0]).toContain('snapshot');
600
607
  expect(obj).not.toHaveProperty('trigger');
601
608
  expect(obj).not.toHaveProperty('triggers');
602
609
  expect(obj).not.toHaveProperty('all_events');
603
- expect(obj).toHaveProperty('summary');
610
+ expect(obj).not.toHaveProperty('summary');
611
+ expect(obj).toHaveProperty('state');
604
612
  });
605
613
 
606
614
  it('errors clearly if feed.json is missing', () => {
@@ -632,7 +640,8 @@ describe('runStreaming — startup backlog', () => {
632
640
 
633
641
  expect(lines.length).toBeGreaterThanOrEqual(2);
634
642
  const first = JSON.parse(lines[0]);
635
- expect(first.caught_up?.count).toBe(5);
643
+ expect(first.events).toHaveLength(5);
644
+ expect(first.events.every((event: string) => event === 'killed')).toBe(true);
636
645
  for (const raw of lines.slice(1)) {
637
646
  expect(JSON.parse(raw)).not.toHaveProperty('caught_up');
638
647
  }
@@ -660,8 +669,7 @@ describe('runStreaming — startup backlog', () => {
660
669
 
661
670
  expect(lines.length).toBeGreaterThanOrEqual(1);
662
671
  const first = JSON.parse(lines[0]);
663
- expect(first.exit_reason).toEqual(['game_start']);
664
- expect(first.caught_up?.count).toBe(3);
672
+ expect(first.events).toEqual(['killed', 'killed', 'killed']);
665
673
  expect(first).not.toHaveProperty('active_sticky_on_attach');
666
674
  });
667
675
  });
@@ -675,7 +683,7 @@ describe('runStreaming — game_over exits', () => {
675
683
  await new Promise((r) => setTimeout(r, 30));
676
684
  appendFileSync(sessionPath, JSON.stringify({ type: 'game_over', tick: 1 }) + '\n');
677
685
  await Promise.race([run, new Promise((_, rej) => setTimeout(() => rej(new Error('did not exit')), 3000))]);
678
- expect(lines.some((l) => JSON.parse(l).exit_reason?.includes('game_over'))).toBe(true);
686
+ expect(lines.some((l) => JSON.parse(l).events?.includes('game_over'))).toBe(true);
679
687
  });
680
688
  });
681
689
 
@@ -716,13 +724,12 @@ describe('runStreaming — matchmaking synthetic events', () => {
716
724
  await new Promise((r) => setTimeout(r, 60));
717
725
  ctrl.abort(); await run;
718
726
 
719
- const exitReasons = lines.flatMap((l) => JSON.parse(l).exit_reason);
720
- expect(exitReasons).toContain('match_waiting');
721
- expect(exitReasons).toContain('match_timeout');
727
+ const eventNames = lines.flatMap((l) => JSON.parse(l).events);
728
+ expect(eventNames).toContain('match_waiting');
729
+ expect(eventNames).toContain('match_timeout');
722
730
 
723
- const timeout = lines.map((l) => JSON.parse(l)).find((o) => o.exit_reason?.includes('match_timeout'));
724
- expect(timeout.next_step).toMatch(/cumulative wait/i);
725
- expect(timeout.events[0].waited_secs).toBe(600);
731
+ const timeout = lines.map((l) => JSON.parse(l)).find((o) => o.events?.includes('match_timeout'));
732
+ expect(timeout.messages[0]).toContain('600');
726
733
  });
727
734
  });
728
735
 
@@ -739,9 +746,9 @@ describe('runStreaming — speech_your_turn fires as notable event', () => {
739
746
  ctrl.abort(); await run;
740
747
 
741
748
  const parsed = lines.map((l) => JSON.parse(l));
742
- const myTurn = parsed.find((o) => o.exit_reason?.includes('speech_your_turn'));
749
+ const myTurn = parsed.find((o) => o.events?.includes('speech_your_turn'));
743
750
  expect(myTurn).toBeDefined();
744
- expect(myTurn!.exit_reason).toContain('speech_your_turn');
751
+ expect(myTurn!.events).toContain('speech_your_turn');
745
752
  });
746
753
  });
747
754
 
@@ -770,7 +777,7 @@ describe('runStreaming — delay buffer', () => {
770
777
  ctrl.abort(); await run;
771
778
 
772
779
  const parsed = lines.map((l) => JSON.parse(l));
773
- const killNotif = parsed.find((o) => o.exit_reason?.includes('kill'));
780
+ const killNotif = parsed.find((o) => o.events?.includes('kill'));
774
781
  expect(killNotif).toBeDefined();
775
782
  });
776
783
 
@@ -800,11 +807,11 @@ describe('runStreaming — delay buffer', () => {
800
807
  ctrl.abort(); await run;
801
808
 
802
809
  const parsed = lines.map((l) => JSON.parse(l));
803
- const merged = parsed.find((o) => o.exit_reason?.includes('kill'));
810
+ const merged = parsed.find((o) => o.events?.includes('kill'));
804
811
  expect(merged).toBeDefined();
805
- expect(merged!.exit_reason).toContain('emergency_started');
806
- expect(merged!.exit_reason).toContain('task_completed');
807
- const notableLines = parsed.filter((o) => !o.exit_reason?.includes('heartbeat'));
812
+ expect(merged!.events).toContain('emergency_started');
813
+ expect(merged!.events).toContain('task_completed');
814
+ const notableLines = parsed.filter((o) => !o.events?.includes('heartbeat'));
808
815
  expect(notableLines.length).toBe(1);
809
816
  });
810
817
 
@@ -832,9 +839,9 @@ describe('runStreaming — delay buffer', () => {
832
839
  ctrl.abort(); await run;
833
840
 
834
841
  const parsed = lines.map((l) => JSON.parse(l));
835
- const merged = parsed.find((o) => o.exit_reason?.includes('kill'));
842
+ const merged = parsed.find((o) => o.events?.includes('kill'));
836
843
  expect(merged).toBeDefined();
837
- expect(merged!.exit_reason).toContain('emergency_started');
844
+ expect(merged!.events).toContain('emergency_started');
838
845
  });
839
846
 
840
847
  it('non-delayable events fire immediately when no delay is active', async () => {
@@ -859,7 +866,7 @@ describe('runStreaming — delay buffer', () => {
859
866
  ctrl.abort(); await run;
860
867
 
861
868
  const parsed = lines.map((l) => JSON.parse(l));
862
- expect(parsed.some((o) => o.exit_reason?.includes('task_completed'))).toBe(true);
869
+ expect(parsed.some((o) => o.events?.includes('task_completed'))).toBe(true);
863
870
  });
864
871
  });
865
872
 
@@ -910,9 +917,9 @@ describe('runStreaming startup monitor payload', () => {
910
917
 
911
918
  expect(lines.length).toBeGreaterThanOrEqual(1);
912
919
  const first = JSON.parse(lines[0]);
913
- expect(first.exit_reason).toEqual(['game_start']);
920
+ expect(first.events).toEqual(['game_start']);
914
921
  expect(first).not.toHaveProperty('initial_payload');
915
- expect(first.next_step).toMatch(/summary/);
922
+ expect(first.messages[0]).toContain('game_start');
916
923
  });
917
924
 
918
925
  it('compacts role_assigned inside caught_up notable events', async () => {
@@ -952,18 +959,8 @@ describe('runStreaming startup monitor payload', () => {
952
959
 
953
960
  expect(lines.length).toBeGreaterThanOrEqual(1);
954
961
  const first = JSON.parse(lines[0]);
955
- const role = first.caught_up?.notable_events?.[0];
956
- expect(role).toEqual({
957
- type: 'role_assigned',
958
- tick: 1,
959
- room: '仓库',
960
- role: 'octopus',
961
- role_display: '章鱼',
962
- faction: 'neutral',
963
- win_condition: '伪装成虾方并达成自己的胜利条件。',
964
- task_kind: 'fake_shrimp',
965
- task_note: 'Fake shrimp tasks: disguise only; no lobster progress.',
966
- tasks: [{ name: '下载数据', room: '通讯' }],
967
- });
962
+ expect(first.events).toEqual(['role_assigned']);
963
+ expect(first.messages[0]).toContain('role_assigned');
964
+ expect(first).not.toHaveProperty('caught_up');
968
965
  });
969
966
  });