@zhushanwen/pi-session-manager 0.3.4 → 0.5.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/package.json +8 -5
- package/skills/session-manager-ext-config/SKILL.md +24 -22
- package/src/__tests__/helpers/extension-harness.ts +5 -5
- package/src/__tests__/helpers/logger-mock.ts +5 -5
- package/src/__tests__/notify-content.test.ts +1 -1
- package/src/__tests__/notify-delivery-discipline.test.ts +125 -0
- package/src/__tests__/session-start-recovery.test.ts +25 -16
- package/src/__tests__/tool-summary.test.ts +169 -0
- package/src/__tests__/watch-orchestration.test.ts +120 -86
- package/src/index.ts +69 -17
- package/src/notify-content.ts +5 -5
- package/src/notify-delivery.ts +87 -0
- package/src/render.ts +22 -0
- package/src/watch-coordinator.ts +40 -17
- package/src/__tests__/notify-ledger-discipline.test.ts +0 -260
- package/src/notify-ledger.ts +0 -121
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// watch-orchestration.test.ts — notify-once U4 extension 编排单测(mock select 响应)。
|
|
2
2
|
// 覆盖:send/create arm(register 三键 + 开表不传 timeout + willNotify 透传)/
|
|
3
3
|
// reason 类别分支 / deathSeq·settleSeq 分组去重(两层攒批模型)/ 两例外(cancelled·orphaned
|
|
4
|
-
// 静默 + 死亡新闻槽)/
|
|
4
|
+
// 静默 + 死亡新闻槽)/ port.deliver 幂等键与通道(外部通道逐条)。
|
|
5
5
|
//
|
|
6
6
|
// watch select 全部走 deferred:开表时挂起,用例按调用序精确控制应答时机与内容
|
|
7
7
|
// (覆盖「晚 respond」「W1 悬置 W2 应答」等时序形态)。
|
|
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite
|
|
|
12
12
|
import { SESSION_MANAGER_MARKER } from "@zhushanwen/extension-protocol";
|
|
13
13
|
// logger mock helper 必须先于 extension-harness(其 import 链触发被 mock 模块加载)
|
|
14
14
|
import { createLoggerModuleMock, getLoggerMock } from "./helpers/logger-mock.ts";
|
|
15
|
+
import { DELIVERY_PORT_SLOT_KEY } from "@zhushanwen/pi-delivery-port";
|
|
15
16
|
import {
|
|
16
17
|
flushMicrotasks,
|
|
17
18
|
makeRespondWatch,
|
|
@@ -26,7 +27,9 @@ vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
|
|
|
26
27
|
|
|
27
28
|
const loggerMock = getLoggerMock();
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
// pi-delivery-port 进程级槽(C-ext-06;recordManagedNotify 的消费入口——unified-
|
|
31
|
+
// delivery 波 2 切换后 watch 应答的投递观测点 = port.deliver)。
|
|
32
|
+
const PORT_SLOT_KEY = DELIVERY_PORT_SLOT_KEY;
|
|
30
33
|
|
|
31
34
|
/** watch 开表调用(deferred)——opts 为开表时传入的 select 第三参原样值 */
|
|
32
35
|
interface DeferredWatch extends WatchInvocation {
|
|
@@ -79,35 +82,62 @@ function emittedIds(h: Harness, event: string): string[] {
|
|
|
79
82
|
return h.emitted.filter((e) => e.event === event).map((e) => e.data.id as string);
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
function registersOf(h: Harness): Array<{ id: string; type: unknown; name: unknown }> {
|
|
85
|
+
function registersOf(h: Harness): Array<{ id: string; type: unknown; name: unknown; kind: unknown }> {
|
|
83
86
|
return h.emitted
|
|
84
87
|
.filter((e) => e.event === "pending:register")
|
|
85
|
-
.map((e) => ({ id: String(e.data.id), type: e.data.type, name: e.data.name }));
|
|
88
|
+
.map((e) => ({ id: String(e.data.id), type: e.data.type, name: e.data.name, kind: e.data.kind }));
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
const SEND_OK = '{"queued":true,"willNotify":true}';
|
|
89
92
|
const SEND_NO_NOTIFY = '{"queued":true,"willNotify":false}';
|
|
90
93
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
type DeliverCall = {
|
|
95
|
+
id: string | undefined;
|
|
96
|
+
customType: string;
|
|
97
|
+
content: string;
|
|
98
|
+
details: unknown;
|
|
99
|
+
collectKey: string[] | undefined;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** 植入投递端口槽(pi-delivery-port 槽镜像的读取目标)——deliver spy 由用例自持;
|
|
103
|
+
* merge 判定在端口侧,本替身逐条受理即 accepted(无窗口语义,recorded 即投递)。 */
|
|
104
|
+
function plantPort(acceptAll = true): Array<DeliverCall> {
|
|
105
|
+
const calls: Array<DeliverCall> = [];
|
|
106
|
+
Reflect.set(globalThis, PORT_SLOT_KEY, {
|
|
107
|
+
current: {
|
|
108
|
+
deliver: (
|
|
109
|
+
envelope: {
|
|
110
|
+
id?: string;
|
|
111
|
+
payload: { customType: string; content: string; details?: unknown };
|
|
112
|
+
},
|
|
113
|
+
opts?: { collectKey?: string[] },
|
|
114
|
+
) => {
|
|
115
|
+
if (!acceptAll) return { kind: "rejected", reason: "persist-failed (fake)" };
|
|
116
|
+
calls.push({
|
|
117
|
+
id: envelope.id,
|
|
118
|
+
customType: envelope.payload.customType,
|
|
119
|
+
content: envelope.payload.content,
|
|
120
|
+
details: envelope.payload.details,
|
|
121
|
+
collectKey: opts?.collectKey,
|
|
122
|
+
});
|
|
123
|
+
return { kind: "accepted", id: envelope.id ?? "gen-id" };
|
|
124
|
+
},
|
|
125
|
+
flush: () => {},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
return calls;
|
|
97
129
|
}
|
|
98
130
|
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
plantLedger(spy as never);
|
|
102
|
-
return spy;
|
|
131
|
+
function deliverSpy(): Array<DeliverCall> {
|
|
132
|
+
return plantPort(true);
|
|
103
133
|
}
|
|
104
134
|
|
|
105
|
-
/**
|
|
135
|
+
/** 「兼容与防御」组共用前置:植入投递端口(deliver spy)→ arm 单笔 send → 应答首个 watch →
|
|
106
136
|
* 排干微任务 → 推进 50ms 攒批窗(三用例的时序骨架逐字同款) */
|
|
107
137
|
async function armSendAndRespondWatch(
|
|
108
138
|
respondValue: unknown,
|
|
109
|
-
): Promise<{
|
|
110
|
-
const
|
|
139
|
+
): Promise<{ delivered: Array<DeliverCall>; h: Harness }> {
|
|
140
|
+
const delivered = deliverSpy();
|
|
111
141
|
const h = createHarness();
|
|
112
142
|
h.actionResults.send = () => SEND_OK;
|
|
113
143
|
await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "x" });
|
|
@@ -115,7 +145,7 @@ async function armSendAndRespondWatch(
|
|
|
115
145
|
h.respondWatch(0, respondValue);
|
|
116
146
|
await flushMicrotasks();
|
|
117
147
|
await vi.advanceTimersByTimeAsync(50);
|
|
118
|
-
return {
|
|
148
|
+
return { delivered, h };
|
|
119
149
|
}
|
|
120
150
|
|
|
121
151
|
beforeEach(() => {
|
|
@@ -125,7 +155,7 @@ beforeEach(() => {
|
|
|
125
155
|
|
|
126
156
|
afterEach(() => {
|
|
127
157
|
vi.useRealTimers();
|
|
128
|
-
Reflect.deleteProperty(globalThis,
|
|
158
|
+
Reflect.deleteProperty(globalThis, PORT_SLOT_KEY);
|
|
129
159
|
});
|
|
130
160
|
|
|
131
161
|
// ── arm:send/create 成功 → register + 开表 + willNotify 透传 ──────────────────
|
|
@@ -137,7 +167,7 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
|
|
|
137
167
|
await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "go" });
|
|
138
168
|
await flushMicrotasks();
|
|
139
169
|
|
|
140
|
-
//
|
|
170
|
+
// 请求侧:send params 携带 sm- 形态 notifyId
|
|
141
171
|
const sendPayload = JSON.parse(h.selectMock.mock.calls[0][1][0]) as {
|
|
142
172
|
action: string;
|
|
143
173
|
params: Record<string, unknown>;
|
|
@@ -145,11 +175,13 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
|
|
|
145
175
|
expect(sendPayload.action).toBe("send");
|
|
146
176
|
expect(String(sendPayload.params.notifyId)).toMatch(/^sm-/);
|
|
147
177
|
|
|
148
|
-
// register:P4 三键契约(label 缓存空 → name 退化 sessionId
|
|
178
|
+
// register:P4 三键契约(label 缓存空 → name 退化 sessionId)+ send 债权无 kind
|
|
179
|
+
// (缺省 = send;result-collect 设计 §3.4)
|
|
149
180
|
const regs = registersOf(h);
|
|
150
181
|
expect(regs).toHaveLength(1);
|
|
151
182
|
expect(regs[0].id).toMatch(/^sm-/);
|
|
152
183
|
expect(regs[0]).toEqual({ id: regs[0].id, type: "session", name: "child-1" });
|
|
184
|
+
expect(regs[0].kind).toBeUndefined();
|
|
153
185
|
|
|
154
186
|
// 开表:同 notifyId、无 timeout(D2/P1 fire-and-forget)
|
|
155
187
|
expect(h.watches).toHaveLength(1);
|
|
@@ -157,7 +189,7 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
|
|
|
157
189
|
expect(h.watches[0].opts?.timeout).toBeUndefined();
|
|
158
190
|
});
|
|
159
191
|
|
|
160
|
-
it("工具结果原样透传:send result 含 willNotify 字段(LLM
|
|
192
|
+
it("工具结果原样透传:send result 含 willNotify 字段(LLM 契约范围显式化)", async () => {
|
|
161
193
|
const h = createHarness();
|
|
162
194
|
h.actionResults.send = () => SEND_OK;
|
|
163
195
|
const result = await runToolForText(h, "send_to_session", {
|
|
@@ -190,7 +222,9 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
|
|
|
190
222
|
expect(regs).toHaveLength(2);
|
|
191
223
|
expect(regs[0].id).toMatch(/^sm-/);
|
|
192
224
|
expect(regs[0]).toEqual({ id: regs[0].id, type: "session", name: "auth-refactor" });
|
|
193
|
-
|
|
225
|
+
// lifetime 死亡债权注册申报 kind:"lifetime"(result-collect 设计 §3.4:emit
|
|
226
|
+
// payload 增字段,collect 缺省等待集据此排除;send 债权无 kind = 缺省)
|
|
227
|
+
expect(regs[1]).toEqual({ id: lifetime, type: "session", name: "auth-refactor", kind: "lifetime" });
|
|
194
228
|
expect(h.watches.map((w) => w.notifyId)).toEqual([regs[0].id, lifetime]);
|
|
195
229
|
|
|
196
230
|
// label 缓存回填:随后 send 的 register name = 工具入参 label(D9 正常路径)
|
|
@@ -222,7 +256,8 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
|
|
|
222
256
|
const regs = registersOf(h);
|
|
223
257
|
expect(regs).toHaveLength(1);
|
|
224
258
|
// lifetime register:type 'session' + name=label(label 缺省 → sessionId 回退前的入参)
|
|
225
|
-
|
|
259
|
+
// + kind:"lifetime" 债权类别标记(result-collect 设计 §3.4)
|
|
260
|
+
expect(regs[0]).toEqual({ id: lifetime, type: "session", name: "L", kind: "lifetime" });
|
|
226
261
|
// lifetime watch 无条件开(死亡通知载体)
|
|
227
262
|
expect(h.watches.map((w) => w.notifyId)).toEqual([lifetime]);
|
|
228
263
|
expect(h.watches[0].opts?.timeout).toBeUndefined();
|
|
@@ -251,9 +286,9 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
|
|
|
251
286
|
});
|
|
252
287
|
});
|
|
253
288
|
|
|
254
|
-
// ── 默认 record + trailing debounce 两层攒批(settle
|
|
289
|
+
// ── 默认 record + trailing debounce 两层攒批(settle 路径) ──────────────────────
|
|
255
290
|
|
|
256
|
-
describe("watch 应答 → 两层攒批 record(settle
|
|
291
|
+
describe("watch 应答 → 两层攒批 record(settle 路径)", () => {
|
|
257
292
|
async function armTwoSends(h: Harness): Promise<{ id1: string; id2: string }> {
|
|
258
293
|
h.actionResults.send = () => SEND_OK;
|
|
259
294
|
await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "a" });
|
|
@@ -266,7 +301,7 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
|
|
|
266
301
|
}
|
|
267
302
|
|
|
268
303
|
it("同 settleSeq 两笔 → 恰一条 record(fulfills 2)+ 逐笔 unregister;trailing 50ms 前不 flush(A6 合批)", async () => {
|
|
269
|
-
const
|
|
304
|
+
const delivered = deliverSpy();
|
|
270
305
|
const h = createHarness();
|
|
271
306
|
const { id1, id2 } = await armTwoSends(h);
|
|
272
307
|
|
|
@@ -280,30 +315,28 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
|
|
|
280
315
|
h.respondWatch(1, JSON.stringify(respond));
|
|
281
316
|
await flushMicrotasks();
|
|
282
317
|
|
|
283
|
-
// trailing debounce:应答到达但 50ms 窗未到 →
|
|
284
|
-
expect(
|
|
318
|
+
// trailing debounce:应答到达但 50ms 窗未到 → 零投递零 unregister
|
|
319
|
+
expect(delivered).toHaveLength(0);
|
|
285
320
|
expect(emittedIds(h, "pending:unregister")).toHaveLength(0);
|
|
286
321
|
|
|
287
322
|
await vi.advanceTimersByTimeAsync(50);
|
|
288
|
-
expect(
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
Record<string, unknown>,
|
|
293
|
-
{ deliveryCustomType: string },
|
|
294
|
-
];
|
|
295
|
-
expect(notifyId).toBe(id1); // 批幂等键 = 批内首个响应 notifyId
|
|
296
|
-
expect(content).toBe(
|
|
323
|
+
expect(delivered).toHaveLength(1);
|
|
324
|
+
const call = delivered[0]!;
|
|
325
|
+
expect(call.id).toBe(id1); // 批幂等键 = 批内首个响应 notifyId(port.deliver 的 id)
|
|
326
|
+
expect(call.content).toBe(
|
|
297
327
|
'Managed session "child-1" (child-1) finished with status "completed". (fulfills 2 requests)',
|
|
298
328
|
);
|
|
299
|
-
expect(details).toEqual({
|
|
329
|
+
expect(call.details).toEqual({
|
|
300
330
|
notifyId: id1,
|
|
301
331
|
sessionId: "child-1",
|
|
302
332
|
reason: "completed",
|
|
303
333
|
fulfills: 2,
|
|
304
334
|
label: "child-1",
|
|
305
335
|
});
|
|
306
|
-
expect(
|
|
336
|
+
expect(call.customType).toBe("managed-session-notify"); // 外部通道(port 批组隔离恒逐条)
|
|
337
|
+
// collectKey = 批内全部债权键(result-collect 设计 §3.4 managed 行:合批条目
|
|
338
|
+
// 载荷幂等键 = 批首债权键,collect 按键集逐键展开定位非首键)
|
|
339
|
+
expect(call.collectKey).toEqual([id1, id2]);
|
|
307
340
|
expect(emittedIds(h, "pending:unregister").sort()).toEqual([id1, id2].sort());
|
|
308
341
|
expect(
|
|
309
342
|
h.emitted.filter((e) => e.event === "pending:unregister").map((e) => e.data.reason),
|
|
@@ -311,7 +344,7 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
|
|
|
311
344
|
});
|
|
312
345
|
|
|
313
346
|
it("不同 settleSeq → 两条 record(A7:每轮一次,seq 边界排除跨轮合流)", async () => {
|
|
314
|
-
const
|
|
347
|
+
const delivered = deliverSpy();
|
|
315
348
|
const h = createHarness();
|
|
316
349
|
const { id1, id2 } = await armTwoSends(h);
|
|
317
350
|
h.respondWatch(
|
|
@@ -324,13 +357,13 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
|
|
|
324
357
|
);
|
|
325
358
|
await flushMicrotasks();
|
|
326
359
|
await vi.advanceTimersByTimeAsync(50);
|
|
327
|
-
expect(
|
|
328
|
-
expect(
|
|
360
|
+
expect(delivered).toHaveLength(2);
|
|
361
|
+
expect(delivered.map((c) => c.id).sort()).toEqual([id1, id2].sort());
|
|
329
362
|
expect(emittedIds(h, "pending:unregister")).toHaveLength(2);
|
|
330
363
|
});
|
|
331
364
|
|
|
332
365
|
it("reason 分类:failed / stopped 应答 → 文案 status 对应(渲染侧 outcome 再映射)", async () => {
|
|
333
|
-
const
|
|
366
|
+
const delivered = deliverSpy();
|
|
334
367
|
const h = createHarness();
|
|
335
368
|
const { id1, id2 } = await armTwoSends(h);
|
|
336
369
|
h.respondWatch(
|
|
@@ -343,14 +376,14 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
|
|
|
343
376
|
);
|
|
344
377
|
await flushMicrotasks();
|
|
345
378
|
await vi.advanceTimersByTimeAsync(50);
|
|
346
|
-
expect(
|
|
347
|
-
const byId = new Map(
|
|
379
|
+
expect(delivered).toHaveLength(2);
|
|
380
|
+
const byId = new Map(delivered.map((c) => [c.id, c.content] as const));
|
|
348
381
|
expect(byId.get(id1)).toContain('status "failed"');
|
|
349
382
|
expect(byId.get(id2)).toContain('status "stopped"');
|
|
350
383
|
});
|
|
351
384
|
|
|
352
|
-
it("跨 session 不合批(分拣键 sessionId
|
|
353
|
-
const
|
|
385
|
+
it("跨 session 不合批(分拣键 sessionId 路径):同 settleSeq 不同 session → 两条 record", async () => {
|
|
386
|
+
const delivered = deliverSpy();
|
|
354
387
|
const h = createHarness();
|
|
355
388
|
h.actionResults.send = () => SEND_OK;
|
|
356
389
|
await runToolCall(h, "send_to_session", { sessionId: "child-a", prompt: "x" });
|
|
@@ -367,13 +400,18 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
|
|
|
367
400
|
);
|
|
368
401
|
await flushMicrotasks();
|
|
369
402
|
await vi.advanceTimersByTimeAsync(50);
|
|
370
|
-
expect(
|
|
371
|
-
const sessions =
|
|
403
|
+
expect(delivered).toHaveLength(2);
|
|
404
|
+
const sessions = delivered.map((c) => (c.details as { sessionId: string }).sessionId);
|
|
372
405
|
expect(sessions.sort()).toEqual(["child-a", "child-b"]);
|
|
373
406
|
});
|
|
374
407
|
|
|
375
|
-
it("
|
|
376
|
-
|
|
408
|
+
it("port rejected(persist-failed)/槽空降级 → false 不改 unregister 语义", async () => {
|
|
409
|
+
Reflect.set(globalThis, PORT_SLOT_KEY, {
|
|
410
|
+
current: {
|
|
411
|
+
deliver: () => ({ kind: "rejected", reason: "persist-failed (fake)" }),
|
|
412
|
+
flush: () => {},
|
|
413
|
+
},
|
|
414
|
+
});
|
|
377
415
|
const h = createHarness();
|
|
378
416
|
await armTwoSends(h);
|
|
379
417
|
const respond = {
|
|
@@ -393,8 +431,8 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
|
|
|
393
431
|
// ── 两例外(D3)─────────────────────────────────────────────────────────────────
|
|
394
432
|
|
|
395
433
|
describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
|
|
396
|
-
it("例外1:cancelled / orphaned → 逐笔即时 unregister(50ms
|
|
397
|
-
const
|
|
434
|
+
it("例外1:cancelled / orphaned → 逐笔即时 unregister(50ms 窗前即到),零投递", async () => {
|
|
435
|
+
const delivered = deliverSpy();
|
|
398
436
|
const h = createHarness();
|
|
399
437
|
const { id1, id2 } = await armTwo();
|
|
400
438
|
h.respondWatch(0, JSON.stringify({ reason: "cancelled", sessionId: "child-1" }));
|
|
@@ -403,10 +441,10 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
|
|
|
403
441
|
|
|
404
442
|
// 逐笔即时(不被攒批窗延迟)
|
|
405
443
|
expect(emittedIds(h, "pending:unregister").sort()).toEqual([id1, id2].sort());
|
|
406
|
-
expect(
|
|
444
|
+
expect(delivered).toHaveLength(0);
|
|
407
445
|
|
|
408
446
|
await vi.advanceTimersByTimeAsync(50);
|
|
409
|
-
expect(
|
|
447
|
+
expect(delivered).toHaveLength(0);
|
|
410
448
|
|
|
411
449
|
async function armTwo() {
|
|
412
450
|
h.actionResults.send = () => SEND_OK;
|
|
@@ -418,7 +456,7 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
|
|
|
418
456
|
});
|
|
419
457
|
|
|
420
458
|
it("例外2:同 (sessionId, deathSeq) 首条死亡新闻 record + fulfills N,同键后续仅 unregister;新 deathSeq 自然发声", async () => {
|
|
421
|
-
const
|
|
459
|
+
const delivered = deliverSpy();
|
|
422
460
|
const h = createHarness();
|
|
423
461
|
const lifetime = "sm-11111111-2222-3333-4444-555555555555";
|
|
424
462
|
h.actionResults.create = () =>
|
|
@@ -449,8 +487,10 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
|
|
|
449
487
|
await flushMicrotasks();
|
|
450
488
|
await vi.advanceTimersByTimeAsync(50);
|
|
451
489
|
|
|
452
|
-
expect(
|
|
453
|
-
const
|
|
490
|
+
expect(delivered).toHaveLength(1);
|
|
491
|
+
const id = delivered[0]!.id;
|
|
492
|
+
const content = delivered[0]!.content;
|
|
493
|
+
const details = delivered[0]!.details as Record<string, unknown>;
|
|
454
494
|
expect(id).toBe(claimId); // 首条 = 批内首个响应(claim 先应答)
|
|
455
495
|
expect(content).toContain(
|
|
456
496
|
'Managed session "doomed" (child-d) finished with status "exited" (exit code: 1).',
|
|
@@ -473,22 +513,22 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
|
|
|
473
513
|
);
|
|
474
514
|
await flushMicrotasks();
|
|
475
515
|
await vi.advanceTimersByTimeAsync(50);
|
|
476
|
-
expect(
|
|
477
|
-
expect(
|
|
478
|
-
expect(
|
|
479
|
-
expect(
|
|
480
|
-
expect(
|
|
516
|
+
expect(delivered).toHaveLength(2);
|
|
517
|
+
expect(delivered[1]!.id).toBe(emittedIds(h, "pending:register")[2]);
|
|
518
|
+
expect(delivered[1]!.content).toContain("(exit code: 2).");
|
|
519
|
+
expect(delivered[1]!.content).not.toContain("fulfills");
|
|
520
|
+
expect(delivered[1]!.content).not.toContain("Full transcript");
|
|
481
521
|
});
|
|
482
522
|
|
|
483
523
|
it("例外2 跨 reason 组同槽:claim 'exited' 与 lifetime 'deleted' 同 deathSeq → 仅首条发声(分拣组保序)", async () => {
|
|
484
|
-
const
|
|
524
|
+
const delivered = deliverSpy();
|
|
485
525
|
const lifetime = "sm-11111111-2222-3333-4444-555555555555";
|
|
486
526
|
const claimId = "sm-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
487
527
|
const h = createHarness([
|
|
488
528
|
{ customType: "pending:register", data: { id: claimId, type: "session", name: "D" } },
|
|
489
529
|
{ customType: "pending:register", data: { id: lifetime, type: "session", name: "D" } },
|
|
490
530
|
]);
|
|
491
|
-
//
|
|
531
|
+
// 经收尾路径开两条 watch(开表序 = entries 序,claim 先到)
|
|
492
532
|
h.handlers.session_start({ type: "session_start" }, h.ctx);
|
|
493
533
|
await flushMicrotasks();
|
|
494
534
|
expect(h.watches).toHaveLength(2);
|
|
@@ -504,9 +544,9 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
|
|
|
504
544
|
await flushMicrotasks();
|
|
505
545
|
await vi.advanceTimersByTimeAsync(50);
|
|
506
546
|
|
|
507
|
-
expect(
|
|
508
|
-
expect(
|
|
509
|
-
expect(
|
|
547
|
+
expect(delivered).toHaveLength(1);
|
|
548
|
+
expect(delivered[0]!.id).toBe(claimId); // 先到先占槽(claim 组先入队)
|
|
549
|
+
expect(delivered[0]!.content).toContain('status "exited"');
|
|
510
550
|
expect(emittedIds(h, "pending:unregister").sort()).toEqual([claimId, lifetime].sort());
|
|
511
551
|
const unregisterPairs = h.emitted
|
|
512
552
|
.filter((e) => e.event === "pending:unregister")
|
|
@@ -521,20 +561,20 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
|
|
|
521
561
|
});
|
|
522
562
|
});
|
|
523
563
|
|
|
524
|
-
// ── 兼容与防御(D6 象限 /
|
|
564
|
+
// ── 兼容与防御(D6 象限 / 形状检查)─────────────────────────────────────────────
|
|
525
565
|
|
|
526
566
|
describe("兼容与防御", () => {
|
|
527
567
|
it("旧 runtime 象限:respond 'null' → 折叠 cancelled 静默 unregister,零 record", async () => {
|
|
528
|
-
const {
|
|
529
|
-
expect(
|
|
568
|
+
const { delivered, h } = await armSendAndRespondWatch("null");
|
|
569
|
+
expect(delivered).toHaveLength(0);
|
|
530
570
|
expect(emittedIds(h, "pending:unregister")).toHaveLength(1);
|
|
531
571
|
// D6 显式静默豁免:null 不是漂移信号,零 warn 留痕
|
|
532
572
|
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
533
573
|
});
|
|
534
574
|
|
|
535
|
-
it("畸形/词表外 payload → 折叠 cancelled
|
|
536
|
-
const {
|
|
537
|
-
expect(
|
|
575
|
+
it("畸形/词表外 payload → 折叠 cancelled 收尾(不落 default 误标、不产通知)+ 漂移 warn 留痕", async () => {
|
|
576
|
+
const { delivered, h } = await armSendAndRespondWatch(JSON.stringify({ reason: "some-future-reason" }));
|
|
577
|
+
expect(delivered).toHaveLength(0);
|
|
538
578
|
expect(emittedIds(h, "pending:unregister")).toHaveLength(1);
|
|
539
579
|
// 协议漂移信号必须留痕:warn 带 notifyId + 漂移形态摘要(词表外 reason 原词)
|
|
540
580
|
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
@@ -546,18 +586,12 @@ describe("兼容与防御", () => {
|
|
|
546
586
|
});
|
|
547
587
|
});
|
|
548
588
|
|
|
549
|
-
it("select 侧 cancelled(应答未达,resolve undefined)→ 不动 pending(残留交下次 session_start
|
|
550
|
-
const {
|
|
589
|
+
it("select 侧 cancelled(应答未达,resolve undefined)→ 不动 pending(残留交下次 session_start 收尾路径)", async () => {
|
|
590
|
+
const { delivered, h } = await armSendAndRespondWatch(undefined);
|
|
551
591
|
expect(emittedIds(h, "pending:unregister")).toHaveLength(0);
|
|
552
|
-
expect(
|
|
592
|
+
expect(delivered).toHaveLength(0);
|
|
553
593
|
});
|
|
554
594
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
plantLedger(vi.fn(() => true) as never, compactionCheck);
|
|
558
|
-
const h = createHarness();
|
|
559
|
-
expect(h.handlers.session_compact).toBeTypeOf("function");
|
|
560
|
-
h.handlers.session_compact({ type: "session_compact" }, h.ctx);
|
|
561
|
-
expect(compactionCheck).toHaveBeenCalledTimes(1);
|
|
562
|
-
});
|
|
595
|
+
// [session_compact → compactionCheck 接线随波 2 账本退役删除]:pi session
|
|
596
|
+
// append-only 契约排除 entry 被清事件(unified-delivery §3.7②),无补写接线可锁。
|
|
563
597
|
});
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type {
|
|
|
5
5
|
ExtensionAPI,
|
|
6
6
|
ExtensionContext,
|
|
7
7
|
ExtensionToolContext,
|
|
8
|
-
SessionCompactEvent,
|
|
9
8
|
SessionStartEvent,
|
|
9
|
+
Theme,
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import {
|
|
12
12
|
SESSION_MANAGER_MARKER,
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
20
20
|
import { Type, type Static, type TObject } from "typebox";
|
|
21
21
|
|
|
22
|
-
import {
|
|
22
|
+
import { renderSessionManagerResult } from "./render.ts";
|
|
23
23
|
import { createWatchCoordinator } from "./watch-coordinator.ts";
|
|
24
24
|
|
|
25
25
|
// 模块级 logger(default export 首行 setPiHandle 注入后自动走 appendEntry 持久化,
|
|
@@ -99,13 +99,25 @@ function newNotifyId(): string {
|
|
|
99
99
|
return `sm-${crypto.randomUUID()}`;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/**
|
|
102
|
+
/** 运行时检查(读侧不信任外部格式):结果是否 JSON object——形状断言前的类型 guard。 */
|
|
103
103
|
function asResultRecord(v: unknown): Record<string, unknown> | undefined {
|
|
104
104
|
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
105
105
|
? (v as Record<string, unknown>)
|
|
106
106
|
: undefined;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** toSummary 的字段守卫:非空 string 才采信(缺字段 = 摘要省略对应段,不抛错——
|
|
110
|
+
* 摘要只是展示辅助,不得反过来影响工具成功路径)。 */
|
|
111
|
+
function stringField(rec: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
112
|
+
const v = rec?.[key];
|
|
113
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function arrayField(rec: Record<string, unknown> | undefined, key: string): unknown[] | undefined {
|
|
117
|
+
const v = rec?.[key];
|
|
118
|
+
return Array.isArray(v) ? v : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
109
121
|
/**
|
|
110
122
|
* 统一的 execute 包装:调用 select 通道并解析结果。
|
|
111
123
|
* 返回标准 AgentToolResult 形状;select 取消/超时/异常/非 JSON 回包是错误路径,
|
|
@@ -122,7 +134,8 @@ async function executeTool(
|
|
|
122
134
|
action: SessionManagerAction,
|
|
123
135
|
params: Record<string, unknown>,
|
|
124
136
|
onResult?: (ctx: ExtensionContext, params: Record<string, unknown>, result: unknown) => void,
|
|
125
|
-
|
|
137
|
+
toSummary?: (params: Record<string, unknown>, result: unknown) => string,
|
|
138
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: { summary: string } | undefined }> {
|
|
126
139
|
const result = await callSessionManager(ctx, action, params);
|
|
127
140
|
if (!result.ok) {
|
|
128
141
|
// 行为微变①(D8,有意):非 JSON 回包从「catch 后
|
|
@@ -147,9 +160,11 @@ async function executeTool(
|
|
|
147
160
|
// 成功路径副作用编排(notify-once U4):send/create 的 willNotify arm(register + 开表)、
|
|
148
161
|
// label 缓存回填(list)——错误路径已 throw,天然不触发。
|
|
149
162
|
onResult?.(ctx, params, parsed);
|
|
163
|
+
// details.summary 一行摘要(工具收起态展示):六工具共用本骨架统一组装,
|
|
164
|
+
// 文案由各工具 cfg.toSummary 从协议 params + 回包产出(未配置 = details 缺省)。
|
|
150
165
|
return {
|
|
151
166
|
content: [{ type: "text" as const, text: raw }],
|
|
152
|
-
details: undefined,
|
|
167
|
+
details: toSummary ? { summary: toSummary(params, parsed) } : undefined,
|
|
153
168
|
};
|
|
154
169
|
}
|
|
155
170
|
|
|
@@ -166,6 +181,13 @@ interface SessionToolConfig<S extends TObject> {
|
|
|
166
181
|
toParams: (params: Static<S>) => Record<string, unknown>
|
|
167
182
|
/** 成功结果钩子(仅非错误通道结果触发)——notify-once 的 arm/label 缓存编排 */
|
|
168
183
|
onResult?: (ctx: ExtensionContext, params: Record<string, unknown>, result: unknown) => void
|
|
184
|
+
/**
|
|
185
|
+
* 一行收起态摘要(details.summary 的产出函数,六工具共用 executeTool 骨架统一
|
|
186
|
+
* 组装):输入 = 协议 params(toParams 产物)+ 成功回包;纯函数、缺数据省略
|
|
187
|
+
* 对应段不抛错。消费方 = taiji GUI 对话流(details.summary)与本包 renderResult
|
|
188
|
+
* 折叠态(pi TUI)。
|
|
189
|
+
*/
|
|
190
|
+
toSummary?: (params: Record<string, unknown>, result: unknown) => string
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
/**
|
|
@@ -182,6 +204,9 @@ function registerSessionTool<S extends TObject>(pi: ExtensionAPI, cfg: SessionTo
|
|
|
182
204
|
exposure: "deferred",
|
|
183
205
|
description: cfg.description,
|
|
184
206
|
parameters: cfg.parameters,
|
|
207
|
+
renderResult(result: unknown, options: { expanded: boolean }, theme: Theme) {
|
|
208
|
+
return renderSessionManagerResult(result, options, theme);
|
|
209
|
+
},
|
|
185
210
|
async execute(
|
|
186
211
|
_toolCallId: string,
|
|
187
212
|
params: Static<S>,
|
|
@@ -190,7 +215,7 @@ function registerSessionTool<S extends TObject>(pi: ExtensionAPI, cfg: SessionTo
|
|
|
190
215
|
// pi 1.0.0:ToolDefinition.execute 第 5 参 ctx 收窄为 ExtensionToolContext
|
|
191
216
|
ctx: ExtensionToolContext,
|
|
192
217
|
) {
|
|
193
|
-
return executeTool(ctx, cfg.action, cfg.toParams(params), cfg.onResult);
|
|
218
|
+
return executeTool(ctx, cfg.action, cfg.toParams(params), cfg.onResult, cfg.toSummary);
|
|
194
219
|
},
|
|
195
220
|
});
|
|
196
221
|
}
|
|
@@ -200,26 +225,18 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
|
|
|
200
225
|
setPiHandle(pi);
|
|
201
226
|
|
|
202
227
|
// watch 桥编排(notify-once U4):工具结果 arm / watch 应答两层攒批 record /
|
|
203
|
-
// session_start
|
|
228
|
+
// session_start 重启收尾路径共用同一闭包状态(label 缓存 + 死亡新闻槽 + 攒批队列)。
|
|
204
229
|
const coordinator = createWatchCoordinator({
|
|
205
230
|
pi,
|
|
206
231
|
callWatch: (ctx, notifyId) => callSessionManager(ctx, "watch", { notifyId }),
|
|
207
232
|
});
|
|
208
233
|
|
|
209
|
-
//
|
|
234
|
+
// 重启收尾路径(D7③):父 pi 死亡后永无 unregister 的活跃 type 'session' register
|
|
210
235
|
// 逐条重开 watch(入参 = entry id 即 notifyId),应答经 watch 处理链静默注销,
|
|
211
236
|
// 活跃集回归基线;每 entry 独立 handler(禁串行 await)在 coordinator 内保证。
|
|
212
|
-
// ledger 装配纪律①②(D3):session_start 无条件 bind + recoverFromSession 成对
|
|
213
|
-
//(唯一装配方形态,与 subagent-workflow bindLedgerHostAndRecover 同款——先装配,
|
|
214
|
-
// 收口腿/watch 应答到达时消费面已就绪)。
|
|
215
237
|
pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
216
|
-
ensureLedgerBound(pi, ctx);
|
|
217
238
|
coordinator.recoverStaleRegisters(ctx);
|
|
218
239
|
});
|
|
219
|
-
// compactionCheck 接线(D3 装配纪律④,P-B4 降级——subagent-workflow 同款先例)
|
|
220
|
-
pi.on("session_compact", (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
|
|
221
|
-
runLedgerCompactionCheck();
|
|
222
|
-
});
|
|
223
240
|
|
|
224
241
|
registerSessionTool(pi, {
|
|
225
242
|
name: "create_managed_session",
|
|
@@ -247,9 +264,18 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
|
|
|
247
264
|
coordinator.armSessionNotify(ctx, notifyId, name);
|
|
248
265
|
}
|
|
249
266
|
if (typeof r.lifetimeNotifyId === "string") {
|
|
250
|
-
|
|
267
|
+
// kind:"lifetime"(result-collect 设计 §3.4):lifetime 死亡债权注册在
|
|
268
|
+
// emit 侧申报类别标记(collect 缺省等待集据此排除);send 债权注册
|
|
269
|
+
// 不带 kind(缺省 = send)。
|
|
270
|
+
coordinator.armSessionNotify(ctx, r.lifetimeNotifyId, name, "lifetime");
|
|
251
271
|
}
|
|
252
272
|
},
|
|
273
|
+
toSummary: (params, result) => {
|
|
274
|
+
const label = stringField(params, "label");
|
|
275
|
+
if (label) return `Created session "${label}"`;
|
|
276
|
+
const sessionId = stringField(asResultRecord(result), "sessionId");
|
|
277
|
+
return sessionId ? `Created session ${sessionId}` : "Created session";
|
|
278
|
+
},
|
|
253
279
|
});
|
|
254
280
|
|
|
255
281
|
registerSessionTool(pi, {
|
|
@@ -268,6 +294,10 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
|
|
|
268
294
|
if (typeof notifyId !== "string" || typeof sessionId !== "string") return;
|
|
269
295
|
coordinator.armSessionNotify(ctx, notifyId, coordinator.labelFor(sessionId));
|
|
270
296
|
},
|
|
297
|
+
toSummary: (params) => {
|
|
298
|
+
const sessionId = stringField(params, "sessionId");
|
|
299
|
+
return sessionId ? `Sent message to ${sessionId}` : "Sent message";
|
|
300
|
+
},
|
|
271
301
|
});
|
|
272
302
|
|
|
273
303
|
registerSessionTool(pi, {
|
|
@@ -277,6 +307,13 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
|
|
|
277
307
|
parameters: ReadSessionHistoryParams,
|
|
278
308
|
action: "history",
|
|
279
309
|
toParams: (p) => ({ sessionId: p.sessionId, tailTurns: p.tailTurns }),
|
|
310
|
+
toSummary: (params, result) => {
|
|
311
|
+
const sessionId = stringField(params, "sessionId");
|
|
312
|
+
const messages = arrayField(asResultRecord(result), "messages");
|
|
313
|
+
if (sessionId && messages) return `Read history of ${sessionId} · ${messages.length} entries`;
|
|
314
|
+
if (sessionId) return `Read history of ${sessionId}`;
|
|
315
|
+
return "Read history";
|
|
316
|
+
},
|
|
280
317
|
});
|
|
281
318
|
|
|
282
319
|
registerSessionTool(pi, {
|
|
@@ -300,6 +337,10 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
|
|
|
300
337
|
);
|
|
301
338
|
}
|
|
302
339
|
},
|
|
340
|
+
toSummary: (_params, result) => {
|
|
341
|
+
const sessions = arrayField(asResultRecord(result), "sessions");
|
|
342
|
+
return sessions ? `Listed ${sessions.length} managed sessions` : "Listed managed sessions";
|
|
343
|
+
},
|
|
303
344
|
});
|
|
304
345
|
|
|
305
346
|
registerSessionTool(pi, {
|
|
@@ -309,6 +350,13 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
|
|
|
309
350
|
parameters: GetSessionStatusParams,
|
|
310
351
|
action: "status",
|
|
311
352
|
toParams: (p) => ({ sessionId: p.sessionId }),
|
|
353
|
+
toSummary: (params, result) => {
|
|
354
|
+
const sessionId = stringField(params, "sessionId");
|
|
355
|
+
const status = stringField(asResultRecord(result), "status");
|
|
356
|
+
if (sessionId && status) return `${sessionId} · ${status}`;
|
|
357
|
+
if (sessionId) return `Status of ${sessionId}`;
|
|
358
|
+
return "Session status";
|
|
359
|
+
},
|
|
312
360
|
});
|
|
313
361
|
|
|
314
362
|
registerSessionTool(pi, {
|
|
@@ -318,5 +366,9 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
|
|
|
318
366
|
parameters: AbortSessionParams,
|
|
319
367
|
action: "abort",
|
|
320
368
|
toParams: (p) => ({ sessionId: p.sessionId }),
|
|
369
|
+
toSummary: (params) => {
|
|
370
|
+
const sessionId = stringField(params, "sessionId");
|
|
371
|
+
return sessionId ? `Aborted ${sessionId}` : "Aborted";
|
|
372
|
+
},
|
|
321
373
|
});
|
|
322
374
|
}
|