@zhushanwen/pi-pending-notifications 0.2.1 → 0.3.1
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/package.json +2 -2
- package/src/__tests__/pending-notifications.test.ts +99 -3
- package/src/index.ts +28 -8
- package/src/state.ts +62 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-pending-notifications",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Cross-extension async operation registration/query mechanism for Pi — prevents message injection during long-running operations.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"@earendil-works/pi-coding-agent": "*",
|
|
29
|
-
"
|
|
29
|
+
"typebox": "*"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"typecheck": "npx tsc --noEmit",
|
|
@@ -16,7 +16,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
16
16
|
|
|
17
17
|
import pendingNotificationsExtension from "../index";
|
|
18
18
|
import type { PendingEntry } from "../state";
|
|
19
|
-
import { createRegistry, getActive, rebuildFromEntries, register, unregister } from "../state";
|
|
19
|
+
import { countActiveFromEntries, createRegistry, getActive, rebuildFromEntries, register, unregister } from "../state";
|
|
20
20
|
|
|
21
21
|
// ── Mock 工具 ───────────────────────────────────────
|
|
22
22
|
|
|
@@ -90,9 +90,15 @@ async function runTool(
|
|
|
90
90
|
params: Record<string, unknown>,
|
|
91
91
|
): Promise<{ content: Array<{ type: string; text: string }>; details: unknown }> {
|
|
92
92
|
const tool = setup.registerToolMock.mock.calls[0][0] as {
|
|
93
|
-
execute: (
|
|
93
|
+
execute: (
|
|
94
|
+
toolCallId: string,
|
|
95
|
+
p: unknown,
|
|
96
|
+
signal: AbortSignal | undefined,
|
|
97
|
+
onUpdate: unknown,
|
|
98
|
+
ctx: ExtensionContext,
|
|
99
|
+
) => Promise<{ content: Array<{ type: string; text: string }>; details: unknown }>;
|
|
94
100
|
};
|
|
95
|
-
return tool.execute(params);
|
|
101
|
+
return tool.execute("test-call-id", params, undefined, undefined, createMockCtx([]));
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
async function getCount(setup: MockSetup): Promise<number> {
|
|
@@ -209,6 +215,17 @@ describe("state pure functions", () => {
|
|
|
209
215
|
expect(comp.activeIds).toEqual([]);
|
|
210
216
|
expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
|
|
211
217
|
});
|
|
218
|
+
|
|
219
|
+
it("entries 含 null/undefined 元素 → 跳过不抛 TypeError(S-10)", () => {
|
|
220
|
+
const comp = rebuildFromEntries(
|
|
221
|
+
createRegistry(),
|
|
222
|
+
[null, makeRegisterEntry("w-1"), undefined],
|
|
223
|
+
"sess-current",
|
|
224
|
+
NOW,
|
|
225
|
+
);
|
|
226
|
+
expect(comp.activeIds).toEqual(["w-1"]);
|
|
227
|
+
expect(comp.expiredToFlush).toEqual([]);
|
|
228
|
+
});
|
|
212
229
|
});
|
|
213
230
|
|
|
214
231
|
describe("normalizeRegisterEntry defaults (via rebuild)", () => {
|
|
@@ -415,3 +432,82 @@ describe("pendingNotificationsExtension factory", () => {
|
|
|
415
432
|
});
|
|
416
433
|
});
|
|
417
434
|
});
|
|
435
|
+
|
|
436
|
+
describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow 复用)", () => {
|
|
437
|
+
const mkRegister = (id: string, type: "subagent" | "workflow" = "subagent", overrides: Record<string, unknown> = {}) => ({
|
|
438
|
+
type: "custom",
|
|
439
|
+
customType: "pending:register",
|
|
440
|
+
data: { id, type, name: id, registeredAt: 1000, expiresAt: 1000 + 3_600_000, sessionId: "s-1", ...overrides },
|
|
441
|
+
});
|
|
442
|
+
const mkUnregister = (id: string, reason = "completed") => ({
|
|
443
|
+
type: "custom",
|
|
444
|
+
customType: "pending:unregister",
|
|
445
|
+
data: { id, reason },
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("空 entries → 0 活跃", () => {
|
|
449
|
+
expect(countActiveFromEntries([])).toEqual({ count: 0, ids: [], entries: [] });
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("纯 register → count = register 数,返回完整 entry", () => {
|
|
453
|
+
const res = countActiveFromEntries([mkRegister("bg-1"), mkRegister("bg-2", "workflow")]);
|
|
454
|
+
expect(res.count).toBe(2);
|
|
455
|
+
expect(res.ids).toEqual(["bg-1", "bg-2"]);
|
|
456
|
+
expect(res.entries[0]).toMatchObject({ id: "bg-1", type: "subagent" });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("register + unregister 同 id → 差集抵消", () => {
|
|
460
|
+
expect(countActiveFromEntries([mkRegister("bg-1"), mkUnregister("bg-1")]).count).toBe(0);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("混合:部分注销 → 只统计仍活跃的", () => {
|
|
464
|
+
const res = countActiveFromEntries([
|
|
465
|
+
mkRegister("bg-1"),
|
|
466
|
+
mkRegister("bg-2"),
|
|
467
|
+
mkUnregister("bg-1"),
|
|
468
|
+
]);
|
|
469
|
+
expect(res.count).toBe(1);
|
|
470
|
+
expect(res.ids).toEqual(["bg-2"]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("types 过滤:只统计指定类型的活跃 pending", () => {
|
|
474
|
+
const entries = [mkRegister("bg-1", "subagent"), mkRegister("wf-1", "workflow")];
|
|
475
|
+
expect(countActiveFromEntries(entries, { types: ["subagent"] }).ids).toEqual(["bg-1"]);
|
|
476
|
+
expect(countActiveFromEntries(entries, { types: ["workflow"] }).ids).toEqual(["wf-1"]);
|
|
477
|
+
expect(countActiveFromEntries(entries, { types: ["subagent", "workflow"] }).count).toBe(2);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("TTL 过期仍判活跃(刻意不校验 expiresAt,对齐 goal continuation 守卫语义)", () => {
|
|
481
|
+
const expired = mkRegister("bg-1", "subagent", { registeredAt: 0, expiresAt: 1 });
|
|
482
|
+
expect(countActiveFromEntries([expired]).count).toBe(1);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("同 id 重复 register → 只算一次", () => {
|
|
486
|
+
expect(countActiveFromEntries([mkRegister("bg-1"), mkRegister("bg-1")]).count).toBe(1);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("entries 含 null/undefined 元素 → 跳过不抛 TypeError(S-10)", () => {
|
|
490
|
+
// S-10 回归:外部调用方可能传入含 null/undefined 的 entries(session 文件坏行/脏数据),
|
|
491
|
+
// 遍历时访问 raw.customType 前必须守卫,否则 TypeError 炸掉整个差集判定。
|
|
492
|
+
const withNulls = [null, undefined, mkRegister("bg-1"), mkUnregister("bg-1"), null];
|
|
493
|
+
expect(countActiveFromEntries(withNulls).count).toBe(0);
|
|
494
|
+
const mixed = [undefined, null, mkRegister("bg-2")];
|
|
495
|
+
expect(countActiveFromEntries(mixed).ids).toEqual(["bg-2"]);
|
|
496
|
+
// register 循环同样守卫:unregister 遍历前的 null 不污染差集
|
|
497
|
+
expect(countActiveFromEntries([null, undefined]).count).toBe(0);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("malformed register(id 非 string)→ 跳过", () => {
|
|
501
|
+
const bad = { type: "custom", customType: "pending:register", data: { id: 42 } };
|
|
502
|
+
const good = mkRegister("bg-1");
|
|
503
|
+
expect(countActiveFromEntries([bad, good]).ids).toEqual(["bg-1"]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("跨 session 残留(fork 继承的 register)不校验 sessionId——由 session_start 重建流程补 expired unregister 抵消", () => {
|
|
507
|
+
// 模拟 P fork 主 session:继承来主 session 的 register(sessionId=s-0),已被 rebuild 补 unregister(expired)
|
|
508
|
+
const inherited = mkRegister("parent-bg", "subagent", { sessionId: "s-0" });
|
|
509
|
+
const flushed = mkUnregister("parent-bg", "expired");
|
|
510
|
+
const own = mkRegister("my-bg");
|
|
511
|
+
expect(countActiveFromEntries([inherited, flushed, own]).ids).toEqual(["my-bg"]);
|
|
512
|
+
});
|
|
513
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import { Type } from "
|
|
26
|
+
import { Type } from "typebox";
|
|
27
27
|
|
|
28
28
|
import {
|
|
29
29
|
createRegistry,
|
|
@@ -38,6 +38,24 @@ import {
|
|
|
38
38
|
unregister,
|
|
39
39
|
} from "./state.ts";
|
|
40
40
|
|
|
41
|
+
// 跨扩展消费 API:goal(continuation 守卫)/ subagent-workflow(agent_end 后代判定)
|
|
42
|
+
// 直接 import 本包的导出,避免各自复制差集逻辑。
|
|
43
|
+
export {
|
|
44
|
+
countActiveFromEntries,
|
|
45
|
+
createRegistry,
|
|
46
|
+
getActive,
|
|
47
|
+
PENDING_TTL_MS,
|
|
48
|
+
type CountActiveOptions,
|
|
49
|
+
type CountActiveResult,
|
|
50
|
+
type PendingEntry,
|
|
51
|
+
type PendingRegistry,
|
|
52
|
+
type PendingStatus,
|
|
53
|
+
type PendingType,
|
|
54
|
+
rebuildFromEntries,
|
|
55
|
+
register,
|
|
56
|
+
unregister,
|
|
57
|
+
} from "./state.ts";
|
|
58
|
+
|
|
41
59
|
/** 工具参数 schema */
|
|
42
60
|
const PendingNotificationsParams = Type.Object({
|
|
43
61
|
action: Type.Union([
|
|
@@ -46,9 +64,12 @@ const PendingNotificationsParams = Type.Object({
|
|
|
46
64
|
]),
|
|
47
65
|
});
|
|
48
66
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
67
|
+
/** pending_notifications 工具的 details 形状。两个分支共用同一结构(items 缺省时 undefined),
|
|
68
|
+
* 让 registerTool 泛型对 TDetails 推断一致,避免 union 细节冲突。 */
|
|
69
|
+
interface PendingToolDetails {
|
|
70
|
+
action: string;
|
|
71
|
+
count: number;
|
|
72
|
+
items?: PendingEntry[];
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
/**
|
|
@@ -218,13 +239,12 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
218
239
|
description:
|
|
219
240
|
"查询当前活跃的异步操作(workflow/subagent)。action=count 返回数量;action=list 返回列表。状态由 EventBus + session entries 维护,无需手动注册。",
|
|
220
241
|
parameters: PendingNotificationsParams,
|
|
221
|
-
execute: async (params: unknown) => {
|
|
222
|
-
const p = params as ToolParams;
|
|
242
|
+
execute: async (_toolCallId: string, params: { action: "count" | "list" }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext): Promise<{ content: { type: "text"; text: string }[]; details: PendingToolDetails }> => {
|
|
223
243
|
const active = getActive(registry);
|
|
224
244
|
|
|
225
|
-
debugLog("debug", `tool ${
|
|
245
|
+
debugLog("debug", `tool ${params.action} requested`, { action: params.action, activeCount: active.length });
|
|
226
246
|
|
|
227
|
-
if (
|
|
247
|
+
if (params.action === "count") {
|
|
228
248
|
return {
|
|
229
249
|
content: [{ type: "text" as const, text: `${active.length} pending operation(s)` }],
|
|
230
250
|
details: { action: "count", count: active.length },
|
package/src/state.ts
CHANGED
|
@@ -108,6 +108,66 @@ export function getActive(registry: PendingRegistry): PendingEntry[] {
|
|
|
108
108
|
return Array.from(registry.operations.values()).filter((op) => op.status === "active");
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** countActiveFromEntries 的过滤选项。 */
|
|
112
|
+
export interface CountActiveOptions {
|
|
113
|
+
/** 只统计指定类型的活跃 pending;缺省 = 全部类型(subagent + workflow) */
|
|
114
|
+
types?: PendingType[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** countActiveFromEntries 的结果。 */
|
|
118
|
+
export interface CountActiveResult {
|
|
119
|
+
count: number;
|
|
120
|
+
ids: string[];
|
|
121
|
+
/** 活跃的完整 entry(含类型/名称/TTL,供调用方展示或后续判断) */
|
|
122
|
+
entries: PendingEntry[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 从持久化 entries 计算活跃 pending 数(register − unregister 差集)。
|
|
127
|
+
*
|
|
128
|
+
* 与 rebuildFromEntries 的分工:本函数只做「有没有活跃 pending」的只读判断,
|
|
129
|
+
* 不写 registry、不判 sessionId/expiresAt(TTL 刻意不校验——长任务 subagent >1h
|
|
130
|
+
* 仍应视为活跃,对齐 goal agent-end 的 continuation 守卫语义)。
|
|
131
|
+
* 调用方:goal(agent_end 时判断是否发 continuation)、subagent-workflow
|
|
132
|
+
* (agent_end 时判断子进程是否有活跃后代,决定是否保持进程等 steer 唤醒)。
|
|
133
|
+
*
|
|
134
|
+
* 注:跨 session 残留(fork 继承的 register)由 index.ts 的 session_start 重建
|
|
135
|
+
* 流程补 unregister(expired) 抵消;本函数只做纯差集,不重复处理。
|
|
136
|
+
*/
|
|
137
|
+
export function countActiveFromEntries(
|
|
138
|
+
entries: unknown[],
|
|
139
|
+
opts?: CountActiveOptions,
|
|
140
|
+
): CountActiveResult {
|
|
141
|
+
const unregistered = new Set<string>();
|
|
142
|
+
for (const raw of entries as EntryLike[]) {
|
|
143
|
+
// S-10:entries 元素可能是 null/undefined(外部调用方/坏数据),
|
|
144
|
+
// EntryLike 断言前先守卫,避免访问 raw.customType 抛 TypeError 炸掉差集判定。
|
|
145
|
+
if (!raw || typeof raw !== "object") continue;
|
|
146
|
+
if (raw.customType !== "pending:unregister") continue;
|
|
147
|
+
const data = (raw.data ?? {}) as UnregisterEntryData;
|
|
148
|
+
if (typeof data.id === "string") unregistered.add(data.id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const active: PendingEntry[] = [];
|
|
152
|
+
const seen = new Set<string>();
|
|
153
|
+
for (const raw of entries as EntryLike[]) {
|
|
154
|
+
if (!raw || typeof raw !== "object") continue;
|
|
155
|
+
if (raw.customType !== "pending:register") continue;
|
|
156
|
+
const data = (raw.data ?? {}) as RegisterEntryData;
|
|
157
|
+
if (typeof data.id !== "string" || unregistered.has(data.id) || seen.has(data.id)) continue;
|
|
158
|
+
seen.add(data.id);
|
|
159
|
+
const entry = normalizeRegisterEntry(data, "");
|
|
160
|
+
if (opts?.types && !opts.types.includes(entry.type)) continue;
|
|
161
|
+
active.push(entry);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
count: active.length,
|
|
166
|
+
ids: active.map((e) => e.id),
|
|
167
|
+
entries: active,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
111
171
|
/** rebuildFromEntries 的结果:重建后的活跃列表 + 需要补注销的 entry */
|
|
112
172
|
export interface RebuildResult {
|
|
113
173
|
/** 重建后识别为 active 的 id 列表(已写入 registry) */
|
|
@@ -140,6 +200,8 @@ export function rebuildFromEntries(
|
|
|
140
200
|
const unregisteredIds = new Set<string>();
|
|
141
201
|
|
|
142
202
|
for (const raw of entries as EntryLike[]) {
|
|
203
|
+
// S-10:同 countActiveFromEntries——null/undefined 元素先守卫再访问字段。
|
|
204
|
+
if (!raw || typeof raw !== "object") continue;
|
|
143
205
|
if (raw.customType === "pending:register") {
|
|
144
206
|
registerEntries.push({ data: (raw.data ?? {}) as RegisterEntryData });
|
|
145
207
|
} else if (raw.customType === "pending:unregister") {
|