@zhushanwen/pi-pending-notifications 0.8.6 → 0.10.0
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.
- package/README.md +3 -3
- package/package.json +9 -2
- package/src/__tests__/collect-results.test.ts +630 -0
- package/src/__tests__/pending-notifications.test.ts +11 -11
- package/src/__tests__/summary.test.ts +151 -0
- package/src/index.ts +343 -11
- package/src/render-result.ts +43 -0
- package/src/state.ts +56 -15
package/README.md
CHANGED
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
- `pending:register { id, type, name }` —— 操作启动(`type` ∈ `workflow` / `subagent` / `bash`)
|
|
25
25
|
- `pending:unregister { id, reason }` —— 操作结束(`reason` 映射为终态 status)
|
|
26
26
|
- 发送方:subagent-core(subagent 与 workflow run 生命周期:启动注册 / 结束注销 / 崩溃恢复 sweep 补注销)、base-tool-enhance(bash 后台任务)、subagent-workflow(仅崩溃恢复补注销)
|
|
27
|
-
- **session entries 唯一状态源**:register/unregister 经 `appendEntry` 落盘,无内存第二份状态;活跃集合 = register − unregister
|
|
27
|
+
- **session entries 唯一状态源**:register/unregister 经 `appendEntry` 落盘,无内存第二份状态;活跃集合 = register − unregister 差集,工具派生视图与写侧去重对同一份 entries 现算,结构上不可分歧
|
|
28
28
|
- **写侧幂等**:重复 register、未知/已注销 id 的 unregister 均忽略,天然不重复落盘
|
|
29
29
|
- **无 TTL**:三类操作均按进程存活口径处理,长任务(>1h)仍视为活跃
|
|
30
30
|
- **跨 session 残留过滤**:查询按当前 session id 过滤 fork 继承的父级注册残留
|
|
31
|
-
- **跨扩展导出**:`countActiveFromEntries()` 供 goal(continuation
|
|
31
|
+
- **跨扩展导出**:`countActiveFromEntries()` 供 goal(continuation 检查)、subagent-workflow(后代存活判定)直接复用差集计算;差集本体单点在 `@zhushanwen/extension-protocol`
|
|
32
32
|
|
|
33
33
|
## 安装
|
|
34
34
|
|
|
@@ -55,7 +55,7 @@ LLM 在决定「继续推进还是等待异步操作」前主动查询:
|
|
|
55
55
|
{ "action": "list" }
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
典型场景:agent 想确认是否真有后台任务在跑(workflow run / subagent / bash
|
|
58
|
+
典型场景:agent 想确认是否真有后台任务在跑(workflow run / subagent / bash 后台命令),避免在异步操作未收尾时提前下结论或重复派发。
|
|
59
59
|
|
|
60
60
|
## 文件结构
|
|
61
61
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-pending-notifications",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Cross-extension async operation registration/query mechanism for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -30,12 +30,19 @@
|
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
33
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
33
34
|
"typebox": "1.3.27"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@zhushanwen/extension-protocol": "0.
|
|
37
|
+
"@zhushanwen/extension-protocol": "0.22.0",
|
|
38
|
+
"@zhushanwen/pi-delivery-port": "0.2.0",
|
|
37
39
|
"@zhushanwen/pi-extension-logger": "0.6.2"
|
|
38
40
|
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@earendil-works/pi-tui": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
39
46
|
"scripts": {
|
|
40
47
|
"typecheck": "npx tsc --noEmit",
|
|
41
48
|
"test": "vitest run"
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// 测试框架:vitest
|
|
2
|
+
// 运行命令:pnpm -C extensions/universal/pending-notifications test
|
|
3
|
+
//
|
|
4
|
+
// collect_results 工具单测(result-collect 设计 §3.1③ 工具面;设计 §5 拆分依据:
|
|
5
|
+
// 工具面独立可测——mock port 句柄)。
|
|
6
|
+
//
|
|
7
|
+
// 测试策略:
|
|
8
|
+
// - vi.mock("@zhushanwen/pi-delivery-port") 全模块 mock:工具经进程级槽消费投递端口,
|
|
9
|
+
// 测试注入 mock handle(collectArm/consume/collectRelease/onCollectArrival 四方法 +
|
|
10
|
+
// 存量面),与 u-p-port 实装解耦(不加载 delivery-port.ts 真实模块)
|
|
11
|
+
// - mock 手法与 pending-notifications.test.ts 同款:最小 mock ExtensionAPI /
|
|
12
|
+
// ExtensionContext,共享 entries 数组 = session JSONL 的单测层镜像(appendEntry
|
|
13
|
+
// mock 同步入账)
|
|
14
|
+
// - 覆盖(任务书目标 6):lifetime 排除生效 / 跨 session 残留反向断言 / 未知 id
|
|
15
|
+
// errors / 第四态返回形态;另覆盖 sequential 声明、abort 正常返回、timeout、
|
|
16
|
+
// in-push-pipeline note、快照重收、port 缺席降级、kind 落盘透传、等待中同步到达
|
|
17
|
+
// 的 outcome 回填(一致性修复 U1:managed 合批「deliver 先于注销」同步段时序)
|
|
18
|
+
|
|
19
|
+
/* eslint-disable taste/no-unsafe-cast -- 测试 mock 构造(pending-notifications.test.ts:15 同款):最小 mock ExtensionAPI/ExtensionContext 无法逐字段满足真实接口,cast 真值由测试断言兜底(mock 被被测代码真实调用,行为不符即红) */
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
23
|
+
|
|
24
|
+
import pendingNotificationsExtension from "../index";
|
|
25
|
+
import { computeCollectWaitKeys, countActiveFromEntries } from "../state";
|
|
26
|
+
|
|
27
|
+
// ── delivery-port 全模块 mock(vi.mock 工厂提升,经 vi.hoisted 共享引用) ──
|
|
28
|
+
|
|
29
|
+
const portMocks = vi.hoisted(() => ({
|
|
30
|
+
getDeliveryPort: vi.fn<() => unknown>(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("@zhushanwen/pi-delivery-port", () => portMocks);
|
|
34
|
+
|
|
35
|
+
// ── Mock 工具(与 pending-notifications.test.ts 同款手法) ──────────────
|
|
36
|
+
|
|
37
|
+
interface HandlerRegistry { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
|
|
38
|
+
sessionStart: ((event: unknown, ctx: ExtensionContext) => void | Promise<void>) | undefined;
|
|
39
|
+
pendingRegister: ((data: unknown) => void) | undefined;
|
|
40
|
+
pendingUnregister: ((data: unknown) => void) | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface MockSessionEntry { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
|
|
44
|
+
customType: string;
|
|
45
|
+
data: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface MockPort { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
|
|
49
|
+
collectArm: ReturnType<typeof vi.fn>;
|
|
50
|
+
consume: ReturnType<typeof vi.fn>;
|
|
51
|
+
collectRelease: ReturnType<typeof vi.fn>;
|
|
52
|
+
onCollectArrival: ReturnType<typeof vi.fn>;
|
|
53
|
+
deliver: ReturnType<typeof vi.fn>;
|
|
54
|
+
recover: ReturnType<typeof vi.fn>;
|
|
55
|
+
metrics: ReturnType<typeof vi.fn>;
|
|
56
|
+
flush: ReturnType<typeof vi.fn>;
|
|
57
|
+
dispose: ReturnType<typeof vi.fn>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface MockSetup { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
|
|
61
|
+
pi: ExtensionAPI;
|
|
62
|
+
handlers: HandlerRegistry;
|
|
63
|
+
entries: MockSessionEntry[];
|
|
64
|
+
appendEntryMock: ReturnType<typeof vi.fn>;
|
|
65
|
+
registerToolMock: ReturnType<typeof vi.fn>;
|
|
66
|
+
port: MockPort;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createMockPort(): MockPort {
|
|
70
|
+
return {
|
|
71
|
+
collectArm: vi.fn(() => []),
|
|
72
|
+
consume: vi.fn(() => []),
|
|
73
|
+
collectRelease: vi.fn(),
|
|
74
|
+
onCollectArrival: vi.fn(() => vi.fn()),
|
|
75
|
+
deliver: vi.fn(),
|
|
76
|
+
recover: vi.fn(() => 0),
|
|
77
|
+
metrics: vi.fn(() => ({ settleRejected: 0, recoveryReplays: 0 })),
|
|
78
|
+
flush: vi.fn(),
|
|
79
|
+
dispose: vi.fn(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createMockSetup(): MockSetup {
|
|
84
|
+
const handlers: HandlerRegistry = {
|
|
85
|
+
sessionStart: undefined,
|
|
86
|
+
pendingRegister: undefined,
|
|
87
|
+
pendingUnregister: undefined,
|
|
88
|
+
};
|
|
89
|
+
// P1 单测层镜像:appendEntry mock 同步入账共享 entries
|
|
90
|
+
const entries: MockSessionEntry[] = [];
|
|
91
|
+
const appendEntryMock = vi.fn((customType: string, data: Record<string, unknown>) => {
|
|
92
|
+
entries.push({ customType, data });
|
|
93
|
+
});
|
|
94
|
+
const registerToolMock = vi.fn();
|
|
95
|
+
const port = createMockPort();
|
|
96
|
+
portMocks.getDeliveryPort.mockReturnValue(port);
|
|
97
|
+
|
|
98
|
+
const pi = {
|
|
99
|
+
appendEntry: appendEntryMock,
|
|
100
|
+
registerTool: registerToolMock,
|
|
101
|
+
sendMessage: vi.fn(),
|
|
102
|
+
on: vi.fn((event: string, handler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>) => {
|
|
103
|
+
if (event === "session_start") handlers.sessionStart = handler;
|
|
104
|
+
}),
|
|
105
|
+
events: {
|
|
106
|
+
emit: vi.fn(),
|
|
107
|
+
on: vi.fn((channel: string, handler: (data: unknown) => void) => {
|
|
108
|
+
if (channel === "pending:register") handlers.pendingRegister = handler;
|
|
109
|
+
if (channel === "pending:unregister") handlers.pendingUnregister = handler;
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
} as unknown as ExtensionAPI;
|
|
113
|
+
|
|
114
|
+
return { pi, handlers, entries, appendEntryMock, registerToolMock, port };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function createMockCtx(setup: MockSetup, sessionId = "sess-current"): ExtensionContext {
|
|
118
|
+
return {
|
|
119
|
+
sessionManager: {
|
|
120
|
+
getEntries: () => setup.entries as unknown[],
|
|
121
|
+
getSessionId: () => sessionId,
|
|
122
|
+
},
|
|
123
|
+
} as unknown as ExtensionContext;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function fireSessionStart(setup: MockSetup, sessionId?: string): void {
|
|
127
|
+
if (!setup.handlers.sessionStart) throw new Error("session_start handler not registered");
|
|
128
|
+
void setup.handlers.sessionStart({ type: "session_start", reason: "resume" }, createMockCtx(setup, sessionId));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface CollectToolDef { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
|
|
132
|
+
executionMode?: string;
|
|
133
|
+
description?: string;
|
|
134
|
+
execute: (
|
|
135
|
+
toolCallId: string,
|
|
136
|
+
p: unknown,
|
|
137
|
+
signal: AbortSignal | undefined,
|
|
138
|
+
onUpdate: unknown,
|
|
139
|
+
ctx: ExtensionContext,
|
|
140
|
+
) => Promise<{
|
|
141
|
+
content: Array<{ type: string; text: string }>;
|
|
142
|
+
details: {
|
|
143
|
+
collected: Array<Record<string, unknown>>;
|
|
144
|
+
stillOpen: Array<{ id: string; note?: string }>;
|
|
145
|
+
errors?: Array<{ id: string; reason: string }>;
|
|
146
|
+
};
|
|
147
|
+
}>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getCollectTool(setup: MockSetup): CollectToolDef {
|
|
151
|
+
const call = setup.registerToolMock.mock.calls.find(
|
|
152
|
+
(c) => (c[0] as { name: string }).name === "collect_results",
|
|
153
|
+
);
|
|
154
|
+
if (!call) throw new Error("collect_results tool not registered");
|
|
155
|
+
return call[0] as CollectToolDef;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function runCollect(
|
|
159
|
+
setup: MockSetup,
|
|
160
|
+
params: Record<string, unknown>,
|
|
161
|
+
signal?: AbortSignal,
|
|
162
|
+
): ReturnType<CollectToolDef["execute"]> {
|
|
163
|
+
return getCollectTool(setup).execute("collect-call-id", params, signal, undefined, createMockCtx(setup));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── 共享 fixtures ───────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const NOW = 1_700_000_000_000;
|
|
169
|
+
|
|
170
|
+
function makeRegisterEntry(id: string, extra: Record<string, unknown> = {}): MockSessionEntry {
|
|
171
|
+
return {
|
|
172
|
+
customType: "pending:register",
|
|
173
|
+
data: {
|
|
174
|
+
id,
|
|
175
|
+
type: "workflow",
|
|
176
|
+
name: `op-${id}`,
|
|
177
|
+
registeredAt: NOW,
|
|
178
|
+
sessionId: "sess-current",
|
|
179
|
+
...extra,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ────────────────────────────────────────────────────
|
|
185
|
+
// state.ts:缺省等待键集 + 曾注册集(collect 工具侧纯函数)
|
|
186
|
+
// ────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
describe("computeCollectWaitKeys(collect_results 缺省等待集,工具侧减法)", () => {
|
|
189
|
+
it("lifetime 债权排除生效:kind=lifetime 的 session register 不入缺省集,其余照常", () => {
|
|
190
|
+
const entries = [
|
|
191
|
+
makeRegisterEntry("sm-life", { type: "session", kind: "lifetime" }),
|
|
192
|
+
makeRegisterEntry("sm-send", { type: "session" }),
|
|
193
|
+
makeRegisterEntry("bt-1", { type: "bash" }),
|
|
194
|
+
];
|
|
195
|
+
expect(computeCollectWaitKeys(entries, "sess-current")).toEqual(["sm-send", "bt-1"]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("升级边沿:无 kind 的旧 session register(send 债权缺省语义)不排除", () => {
|
|
199
|
+
const entries = [makeRegisterEntry("sm-legacy", { type: "session" })];
|
|
200
|
+
expect(computeCollectWaitKeys(entries, "sess-current")).toEqual(["sm-legacy"]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("[跨 session 残留反向断言] sessionId ≠ 当前的继承注册不入缺省集;本 session 计入", () => {
|
|
204
|
+
// fork 继承的父级注册残留:不滤则子 session 缺省 collect 恒挂继承残留(设计 §3.1③)
|
|
205
|
+
const entries = [
|
|
206
|
+
makeRegisterEntry("parent-bg", { sessionId: "sess-old" }),
|
|
207
|
+
makeRegisterEntry("own-bg"),
|
|
208
|
+
];
|
|
209
|
+
expect(computeCollectWaitKeys(entries, "sess-current")).toEqual(["own-bg"]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("entry 缺 sessionId 的旧形态条目视为本 session(不过滤,宁放行不误逐)", () => {
|
|
213
|
+
const legacy: MockSessionEntry = {
|
|
214
|
+
customType: "pending:register",
|
|
215
|
+
data: { id: "old-1", type: "subagent", name: "o", registeredAt: NOW },
|
|
216
|
+
};
|
|
217
|
+
expect(computeCollectWaitKeys([legacy], "sess-current")).toEqual(["old-1"]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("currentSessionId 空串回退(session_start 未到):不过滤", () => {
|
|
221
|
+
const entries = [makeRegisterEntry("a", { sessionId: "s-other" }), makeRegisterEntry("b")];
|
|
222
|
+
expect(computeCollectWaitKeys(entries, "")).toEqual(["a", "b"]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("[共享函数缺省语义不动] countActiveFromEntries 仍计 lifetime 为活跃(goal/subagent-workflow 零变化)", () => {
|
|
226
|
+
// 排除只发生在 computeCollectWaitKeys(工具侧减法)——countActiveFromEntries
|
|
227
|
+
// 缺省语义不变是任务书机械判据③与设计 §3.4 排除归属显式约定的锁定面。
|
|
228
|
+
const entries = [makeRegisterEntry("sm-life", { type: "session", kind: "lifetime" })];
|
|
229
|
+
expect(countActiveFromEntries(entries).ids).toEqual(["sm-life"]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// scanPendingFields(终局回填字段视图)的用例已随 MF-2-1 单点收敛移至 protocol:
|
|
234
|
+
// packages/extension-protocol/src/pending-entries.test.ts「字段映射视图」describe
|
|
235
|
+
// (register 首见 type + 注销 status 词表成员判定,同一规则供本包回填与
|
|
236
|
+
// delivery-port 快照基线消费)。
|
|
237
|
+
|
|
238
|
+
// ────────────────────────────────────────────────────
|
|
239
|
+
// collect_results 工具(mock port 注入)
|
|
240
|
+
// ────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
describe("collect_results tool", () => {
|
|
243
|
+
let setup: MockSetup;
|
|
244
|
+
|
|
245
|
+
beforeEach(() => {
|
|
246
|
+
vi.useFakeTimers();
|
|
247
|
+
vi.setSystemTime(NOW);
|
|
248
|
+
setup = createMockSetup();
|
|
249
|
+
pendingNotificationsExtension(setup.pi);
|
|
250
|
+
fireSessionStart(setup);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
afterEach(() => {
|
|
254
|
+
portMocks.getDeliveryPort.mockReset();
|
|
255
|
+
vi.useRealTimers();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("工具注册形态:name=collect_results、executionMode=sequential", () => {
|
|
259
|
+
const tool = getCollectTool(setup);
|
|
260
|
+
expect(tool.executionMode).toBe("sequential");
|
|
261
|
+
expect(tool.description).toBeTruthy();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("无 ids 且无活跃 pending → 立即空返回,端口零触达(等待集为空不进入等待)", async () => {
|
|
265
|
+
const res = await runCollect(setup, {});
|
|
266
|
+
|
|
267
|
+
expect(res.details.collected).toEqual([]);
|
|
268
|
+
expect(res.details.stillOpen).toEqual([]);
|
|
269
|
+
expect(res.details.errors).toBeUndefined();
|
|
270
|
+
expect(portMocks.getDeliveryPort).not.toHaveBeenCalled();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("缺省集 = 活跃 − lifetime 债权 − 跨 session 残留:collectArm 只收到本 session 非 lifetime 键", async () => {
|
|
274
|
+
setup.entries.push(
|
|
275
|
+
makeRegisterEntry("sm-life", { type: "session", kind: "lifetime" }),
|
|
276
|
+
makeRegisterEntry("parent-bg", { sessionId: "sess-old" }),
|
|
277
|
+
makeRegisterEntry("own-1"),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
await runCollect(setup, {});
|
|
281
|
+
|
|
282
|
+
expect(setup.port.collectArm).toHaveBeenCalledWith(["own-1"]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("只有 lifetime 债权时缺省 collect 立即空返回(排除后等待集为空,不挂满超时)", async () => {
|
|
286
|
+
setup.entries.push(makeRegisterEntry("sm-life", { type: "session", kind: "lifetime" }));
|
|
287
|
+
|
|
288
|
+
const res = await runCollect(setup, {});
|
|
289
|
+
|
|
290
|
+
expect(res.details).toEqual({ collected: [], stillOpen: [] });
|
|
291
|
+
expect(setup.port.collectArm).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("快照 ready 合并进 collected(已完成收取,读侧快照;无等待则不订阅到达回调)", async () => {
|
|
295
|
+
setup.entries.push(makeRegisterEntry("w-1"));
|
|
296
|
+
setup.port.collectArm.mockReturnValue([
|
|
297
|
+
{ id: "w-1", state: "ready", entry: { type: "workflow", outcome: "completed", content: "done!" } },
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
const res = await runCollect(setup, {});
|
|
301
|
+
|
|
302
|
+
expect(res.details.collected).toEqual([
|
|
303
|
+
{ id: "w-1", type: "workflow", outcome: "completed", content: "done!" },
|
|
304
|
+
]);
|
|
305
|
+
expect(res.details.stillOpen).toEqual([]);
|
|
306
|
+
expect(setup.port.onCollectArrival).not.toHaveBeenCalled();
|
|
307
|
+
// 快照收取不写新终局(读侧快照),但等待集登记须解散
|
|
308
|
+
expect(setup.port.consume).toHaveBeenCalled();
|
|
309
|
+
expect(setup.port.collectRelease).toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("in-push-pipeline → stillOpen 附 note(不收取不等待)", async () => {
|
|
313
|
+
setup.entries.push(makeRegisterEntry("w-2"));
|
|
314
|
+
setup.port.collectArm.mockReturnValue([{ id: "w-2", state: "in-push-pipeline" }]);
|
|
315
|
+
|
|
316
|
+
const res = await runCollect(setup, {});
|
|
317
|
+
|
|
318
|
+
expect(res.details.collected).toEqual([]);
|
|
319
|
+
expect(res.details.stillOpen).toEqual([
|
|
320
|
+
{ id: "w-2", note: expect.stringContaining("push pipeline") },
|
|
321
|
+
]);
|
|
322
|
+
expect(setup.port.collectRelease).toHaveBeenCalled();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("第四态 unregistered-no-entry 返回形态:outcome=注销 status + note、无 content、不虚构载荷", async () => {
|
|
326
|
+
setup.entries.push(makeRegisterEntry("sm-1"));
|
|
327
|
+
setup.port.collectArm.mockReturnValue([
|
|
328
|
+
{
|
|
329
|
+
id: "sm-1",
|
|
330
|
+
state: "unregistered-no-entry",
|
|
331
|
+
entry: { type: "session", outcome: "cancelled", note: "settled via unregister entry, no payload" },
|
|
332
|
+
},
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
const res = await runCollect(setup, {});
|
|
336
|
+
|
|
337
|
+
expect(res.details.collected).toEqual([
|
|
338
|
+
{ id: "sm-1", type: "session", outcome: "cancelled", note: "settled via unregister entry, no payload" },
|
|
339
|
+
]);
|
|
340
|
+
expect("content" in res.details.collected[0]).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("到达等待路径:collectArm 等待 → 到达回调 consume → 全键到齐返回 collected 并解散等待集", async () => {
|
|
344
|
+
setup.entries.push(makeRegisterEntry("w-1"));
|
|
345
|
+
setup.port.collectArm.mockReturnValue([{ id: "w-1", state: "waiting" }]);
|
|
346
|
+
|
|
347
|
+
const promise = runCollect(setup, {});
|
|
348
|
+
// execute 同步段已跑到 await(collectArm + onCollectArrival 订阅完成)
|
|
349
|
+
const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
|
|
350
|
+
const unsubscribe = setup.port.onCollectArrival.mock.results[0].value as ReturnType<typeof vi.fn>;
|
|
351
|
+
// 端口 consume 的真实返回形态(MF-1-6):只含 {id, content, details}——type/outcome
|
|
352
|
+
// 由工具侧终局回填(register entry 在册 → type;注销不在册 → outcome 缺省)
|
|
353
|
+
setup.port.consume.mockReturnValueOnce([
|
|
354
|
+
{ id: "w-1", content: "payload" },
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
arrivalCb();
|
|
358
|
+
const res = await promise;
|
|
359
|
+
|
|
360
|
+
expect(res.details.collected).toEqual([
|
|
361
|
+
{ id: "w-1", type: "workflow", content: "payload" },
|
|
362
|
+
]);
|
|
363
|
+
expect(res.details.stillOpen).toEqual([]);
|
|
364
|
+
expect(setup.port.collectRelease).toHaveBeenCalledTimes(1);
|
|
365
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("[同步到达 outcome 回填] 等待中到达的条目:consume 时点注销未落盘 outcome 取空,返回组装前从注销 entry 回填(U1)", async () => {
|
|
369
|
+
// 时序镜像(managed flushBatch,watch-coordinator.ts:323-329):deliver(到达
|
|
370
|
+
// 回调在 deliver 栈内同步触发 pullArrived→consume,此刻注销 entry 未落盘)→
|
|
371
|
+
// 同步段后半 unregisterPending 落盘 → await 恢复后回填生效。
|
|
372
|
+
setup.entries.push(makeRegisterEntry("sm-1", { type: "session" }));
|
|
373
|
+
setup.port.collectArm.mockReturnValue([{ id: "sm-1", state: "waiting" }]);
|
|
374
|
+
|
|
375
|
+
const promise = runCollect(setup, {});
|
|
376
|
+
const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
|
|
377
|
+
// 端口 consume 的真实返回形态:载荷在、type/outcome 取不到(注销尚未落盘——
|
|
378
|
+
// register 已落盘但本用例镜像 deliver 栈内时点,mock 按最简形态不给 type)
|
|
379
|
+
setup.port.consume.mockReturnValueOnce([
|
|
380
|
+
{ id: "sm-1", content: "2 queued messages done", details: { fulfills: 2 } },
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
arrivalCb(); // deliver 同步栈前半:consume 取走载荷,outcome 缺失
|
|
384
|
+
setup.handlers.pendingUnregister!({ id: "sm-1", reason: "completed" }); // 同步栈后半:注销落盘(listener 走 mapReasonToStatus)
|
|
385
|
+
|
|
386
|
+
const res = await promise;
|
|
387
|
+
|
|
388
|
+
expect(res.details.collected).toEqual([
|
|
389
|
+
{ id: "sm-1", type: "session", outcome: "completed", content: "2 queued messages done", details: { fulfills: 2 } },
|
|
390
|
+
]);
|
|
391
|
+
// 注销 entry 已由真实 listener 落盘(回填源在本 session entries 现算)
|
|
392
|
+
expect(
|
|
393
|
+
setup.entries.some((e) => e.customType === "pending:unregister" && e.data.id === "sm-1"),
|
|
394
|
+
).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("[outcome 回填反向断言] 注销 entry 不在册的收取条目 outcome 保持 undefined(不虚构终态)", async () => {
|
|
398
|
+
setup.entries.push(makeRegisterEntry("w-1"));
|
|
399
|
+
setup.port.collectArm.mockReturnValue([{ id: "w-1", state: "waiting" }]);
|
|
400
|
+
|
|
401
|
+
const promise = runCollect(setup, {});
|
|
402
|
+
const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
|
|
403
|
+
setup.port.consume.mockReturnValueOnce([
|
|
404
|
+
{ id: "w-1", content: "payload without unregister" },
|
|
405
|
+
]);
|
|
406
|
+
|
|
407
|
+
arrivalCb();
|
|
408
|
+
const res = await promise;
|
|
409
|
+
|
|
410
|
+
expect(res.details.collected).toEqual([
|
|
411
|
+
{ id: "w-1", type: "workflow", outcome: undefined, content: "payload without unregister" },
|
|
412
|
+
]);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("超时路径:已收部分进 collected、未完成进 stillOpen(诚实返回,不抛错)", async () => {
|
|
416
|
+
setup.entries.push(makeRegisterEntry("fast"), makeRegisterEntry("slow"));
|
|
417
|
+
setup.port.collectArm.mockReturnValue([
|
|
418
|
+
{ id: "fast", state: "waiting" },
|
|
419
|
+
{ id: "slow", state: "waiting" },
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
const promise = runCollect(setup, { timeout_ms: 5_000 });
|
|
423
|
+
const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
|
|
424
|
+
setup.port.consume
|
|
425
|
+
.mockReturnValueOnce([{ id: "fast", content: "fast done" }])
|
|
426
|
+
.mockReturnValue([]);
|
|
427
|
+
|
|
428
|
+
arrivalCb(); // fast 到达,slow 仍等
|
|
429
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
430
|
+
const res = await promise;
|
|
431
|
+
|
|
432
|
+
expect(res.details.collected).toEqual([{ id: "fast", type: "workflow", content: "fast done" }]);
|
|
433
|
+
expect(res.details.stillOpen).toEqual([{ id: "slow" }]);
|
|
434
|
+
expect(setup.port.collectRelease).toHaveBeenCalledTimes(1);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("timeout_ms 缺省 = 3_600_000(量级决策 §3.3:缺省 1h 前不判超时)", async () => {
|
|
438
|
+
setup.entries.push(makeRegisterEntry("w-1"));
|
|
439
|
+
setup.port.collectArm.mockReturnValue([{ id: "w-1", state: "waiting" }]);
|
|
440
|
+
|
|
441
|
+
const promise = runCollect(setup, {});
|
|
442
|
+
await vi.advanceTimersByTimeAsync(3_599_999);
|
|
443
|
+
// 最后一毫秒之前:缺省超时 timer 仍在(未判超时)
|
|
444
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
445
|
+
|
|
446
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
447
|
+
const res = await promise;
|
|
448
|
+
expect(res.details.stillOpen).toEqual([{ id: "w-1" }]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("abort 捕获后正常 return(禁抛错路径):已收部分返回、未收进 stillOpen", async () => {
|
|
452
|
+
setup.entries.push(makeRegisterEntry("w-1"), makeRegisterEntry("w-2"));
|
|
453
|
+
setup.port.collectArm.mockReturnValue([
|
|
454
|
+
{ id: "w-1", state: "waiting" },
|
|
455
|
+
{ id: "w-2", state: "waiting" },
|
|
456
|
+
]);
|
|
457
|
+
const controller = new AbortController();
|
|
458
|
+
|
|
459
|
+
const promise = runCollect(setup, {}, controller.signal);
|
|
460
|
+
const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
|
|
461
|
+
setup.port.consume
|
|
462
|
+
.mockReturnValueOnce([{ id: "w-1", content: "partial" }])
|
|
463
|
+
.mockReturnValue([]);
|
|
464
|
+
|
|
465
|
+
arrivalCb(); // w-1 到达
|
|
466
|
+
controller.abort(); // 用户打断
|
|
467
|
+
const res = await promise; // 不抛错即通过
|
|
468
|
+
|
|
469
|
+
expect(res.details.collected).toEqual([{ id: "w-1", type: "workflow", content: "partial" }]);
|
|
470
|
+
expect(res.details.stillOpen).toEqual([{ id: "w-2" }]);
|
|
471
|
+
expect(setup.port.collectRelease).toHaveBeenCalledTimes(1);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("调用前已 aborted 的 signal:直接返回、不订阅到达回调", async () => {
|
|
475
|
+
setup.entries.push(makeRegisterEntry("w-1"));
|
|
476
|
+
setup.port.collectArm.mockReturnValue([{ id: "w-1", state: "waiting" }]);
|
|
477
|
+
const controller = new AbortController();
|
|
478
|
+
controller.abort();
|
|
479
|
+
|
|
480
|
+
const res = await runCollect(setup, {}, controller.signal);
|
|
481
|
+
|
|
482
|
+
expect(res.details.stillOpen).toEqual([{ id: "w-1" }]);
|
|
483
|
+
expect(setup.port.onCollectArrival).not.toHaveBeenCalled();
|
|
484
|
+
expect(setup.port.collectRelease).toHaveBeenCalledTimes(1);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("未知 id → errors 逐条列出、不阻断已知子集(collectArm 收全量键,unknown 态由端口定态回流)", async () => {
|
|
488
|
+
setup.entries.push(makeRegisterEntry("w-1"));
|
|
489
|
+
setup.port.collectArm.mockReturnValue([
|
|
490
|
+
{ id: "w-1", state: "waiting" },
|
|
491
|
+
{ id: "ghost", state: "unknown" },
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
const promise = runCollect(setup, { ids: ["w-1", "ghost", "ghost"] });
|
|
495
|
+
const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
|
|
496
|
+
setup.port.consume.mockReturnValueOnce([{ id: "w-1", content: "ok" }]);
|
|
497
|
+
|
|
498
|
+
arrivalCb();
|
|
499
|
+
const res = await promise;
|
|
500
|
+
|
|
501
|
+
// 已知子集照常等待并收取;显式 ids 全量(去重)交端口定态(MF-1-5:未知判定单点在端口)
|
|
502
|
+
expect(setup.port.collectArm).toHaveBeenCalledWith(["w-1", "ghost"]);
|
|
503
|
+
expect(res.details.collected).toEqual([{ id: "w-1", type: "workflow", content: "ok" }]);
|
|
504
|
+
// 未知 id 去重后逐条列出
|
|
505
|
+
expect(res.details.errors).toEqual([
|
|
506
|
+
{ id: "ghost", reason: expect.stringContaining("unknown id") },
|
|
507
|
+
]);
|
|
508
|
+
expect(res.content[0].text).toContain("ghost");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("全部 ids 未知 → 立即返回 errors(collectArm 定态全 unknown,不进入等待)", async () => {
|
|
512
|
+
setup.port.collectArm.mockReturnValue([
|
|
513
|
+
{ id: "ghost-a", state: "unknown" },
|
|
514
|
+
{ id: "ghost-b", state: "unknown" },
|
|
515
|
+
]);
|
|
516
|
+
|
|
517
|
+
const res = await runCollect(setup, { ids: ["ghost-a", "ghost-b"] });
|
|
518
|
+
|
|
519
|
+
expect(res.details.collected).toEqual([]);
|
|
520
|
+
expect(res.details.stillOpen).toEqual([]);
|
|
521
|
+
expect(res.details.errors).toHaveLength(2);
|
|
522
|
+
// 未知键不登记等待集:不订阅到达回调、不挂满超时
|
|
523
|
+
expect(setup.port.onCollectArrival).not.toHaveBeenCalled();
|
|
524
|
+
expect(setup.port.collectArm).toHaveBeenCalledWith(["ghost-a", "ghost-b"]);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("已完结 id(register+unregister 在册)不算未知 → collectArm 收键(第四态/快照收取入口)", async () => {
|
|
528
|
+
setup.entries.push(
|
|
529
|
+
makeRegisterEntry("done-1"),
|
|
530
|
+
{ customType: "pending:unregister", data: { id: "done-1", reason: "completed", status: "completed" } },
|
|
531
|
+
);
|
|
532
|
+
setup.port.collectArm.mockReturnValue([
|
|
533
|
+
{
|
|
534
|
+
id: "done-1",
|
|
535
|
+
state: "unregistered-no-entry",
|
|
536
|
+
entry: { type: "workflow", outcome: "completed", note: "no payload" },
|
|
537
|
+
},
|
|
538
|
+
]);
|
|
539
|
+
|
|
540
|
+
const res = await runCollect(setup, { ids: ["done-1"] });
|
|
541
|
+
|
|
542
|
+
expect(setup.port.collectArm).toHaveBeenCalledWith(["done-1"]);
|
|
543
|
+
expect(res.details.errors).toBeUndefined();
|
|
544
|
+
expect(res.details.collected).toEqual([
|
|
545
|
+
{ id: "done-1", type: "workflow", outcome: "completed", note: "no payload" },
|
|
546
|
+
]);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("重复收取 = 快照重收:同 id 二次 collect 再次返回原载荷(工具侧无拒绝分支)", async () => {
|
|
550
|
+
setup.entries.push(makeRegisterEntry("w-1"));
|
|
551
|
+
setup.port.collectArm.mockReturnValue([
|
|
552
|
+
{ id: "w-1", state: "ready", entry: { type: "workflow", outcome: "completed", content: "payload" } },
|
|
553
|
+
]);
|
|
554
|
+
|
|
555
|
+
const first = await runCollect(setup, { ids: ["w-1"] });
|
|
556
|
+
const second = await runCollect(setup, { ids: ["w-1"] });
|
|
557
|
+
|
|
558
|
+
expect(first.details.collected).toEqual(second.details.collected);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("port 缺席(槽未装配)→ 正常返回不可用文案 + 不抛错", async () => {
|
|
562
|
+
portMocks.getDeliveryPort.mockReturnValue(undefined);
|
|
563
|
+
setup.entries.push(makeRegisterEntry("w-1"));
|
|
564
|
+
|
|
565
|
+
const res = await runCollect(setup, {});
|
|
566
|
+
|
|
567
|
+
expect(res.details.collected).toEqual([]);
|
|
568
|
+
expect(res.details.stillOpen).toEqual([]);
|
|
569
|
+
expect(res.content[0].text).toContain("unavailable");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("content 文本承载收取载荷(collected content 随文本到达 agent)", async () => {
|
|
573
|
+
setup.entries.push(makeRegisterEntry("w-1"));
|
|
574
|
+
setup.port.collectArm.mockReturnValue([
|
|
575
|
+
{ id: "w-1", state: "ready", entry: { type: "workflow", outcome: "completed", content: "result body" } },
|
|
576
|
+
]);
|
|
577
|
+
|
|
578
|
+
const res = await runCollect(setup, {});
|
|
579
|
+
|
|
580
|
+
expect(res.content[0].text).toContain("w-1");
|
|
581
|
+
expect(res.content[0].text).toContain("result body");
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// ────────────────────────────────────────────────────
|
|
586
|
+
// kind 读取链写侧落点(pending:register listener 落盘透传)
|
|
587
|
+
// ────────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
describe("pending:register kind 透传(lifetime 标记落盘)", () => {
|
|
590
|
+
let setup: MockSetup;
|
|
591
|
+
|
|
592
|
+
beforeEach(() => {
|
|
593
|
+
vi.useFakeTimers();
|
|
594
|
+
vi.setSystemTime(NOW);
|
|
595
|
+
setup = createMockSetup();
|
|
596
|
+
pendingNotificationsExtension(setup.pi);
|
|
597
|
+
fireSessionStart(setup);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
afterEach(() => {
|
|
601
|
+
portMocks.getDeliveryPort.mockReset();
|
|
602
|
+
vi.useRealTimers();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("register event 带 kind=lifetime → 落盘 entry data 透传 kind;读取侧归一化回读", () => {
|
|
606
|
+
setup.handlers.pendingRegister!({ id: "sm-life", type: "session", name: "ms", kind: "lifetime" });
|
|
607
|
+
|
|
608
|
+
const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
|
|
609
|
+
expect(regCall).toBeDefined();
|
|
610
|
+
expect(regCall![1]).toMatchObject({ id: "sm-life", kind: "lifetime" });
|
|
611
|
+
|
|
612
|
+
// 读取链闭环:normalizeRegisterEntry 白名单透传后工具侧可读到 kind
|
|
613
|
+
const res = countActiveFromEntries(setup.entries as unknown[]);
|
|
614
|
+
expect(res.entries[0].kind).toBe("lifetime");
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("register event 不带 kind → 落盘 entry 无 kind 键(非 managed 注册零字段增量)", () => {
|
|
618
|
+
setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "op" });
|
|
619
|
+
|
|
620
|
+
const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
|
|
621
|
+
expect("kind" in regCall![1]).toBe(false);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("register event kind 值不在白名单(如未来新值)→ 不透传(缺省 send 债权语义)", () => {
|
|
625
|
+
setup.handlers.pendingRegister!({ id: "w-x", type: "session", name: "ms", kind: "future-kind" });
|
|
626
|
+
|
|
627
|
+
const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
|
|
628
|
+
expect("kind" in regCall![1]).toBe(false);
|
|
629
|
+
});
|
|
630
|
+
});
|