// src/__tests__/dialog-queue.test.ts // // W2 红灯测试:dialog-queue.ts — L2 跨子进程全局 dialog 串行队列。 // // 测试对象:extensions/universal/subagent-workflow/src/execution/dialog-queue.ts(新建) // 契约来源:.fix-plans/00-master-summary.md §一 冲突 3(L2 DialogGlobalQueue 设计) // // DialogGlobalQueue 设计要点: // - 进程单例,跨所有子进程共享,串行所有 dialog 类请求(isDialogMethod===true) // - enqueue(req, handler): Promise — 入队,返回 Promise // - FIFO 串行:前一个 handler resolve 后才处理下一个 // - SR-4:入队项带 child 引用,child close 时把该 child 的 pending dialog 全部 // reject 为 cancelled(防 Promise 永挂 + 内存泄漏) // - handler 抛错兜底:catch → appendEntry "subagent:dialog-handler-failed" // → 回 {cancelled:true} → 继续处理下一个(不能让一个失败卡死队列) // - 调用方约定只对 dialog 类调 enqueue;fire-and-forget 由调用方(factory 层) // 直接调 handler 不入队。enqueue 内仍防御性兼容 fire-and-forget(直接调 handler) // // 4 个 TC-E4 测试 case: // 1. 3 个并发 dialog 请求按 FIFO 顺序串行处理 // 2. SR-4: child close 时 pending dialog reject 为 cancelled // 3. handler 抛错后队列继续处理下一个 // 4. fire-and-forget method 不入队(直接调 handler) // // 红灯原因:dialog-queue.ts 尚未创建,import 失败。 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DialogGlobalQueue, type UiRequest, type UiResponse } from "../dialog-queue.ts"; // ── 类型助手 ──────────────────────────────────────────────── // UiRequest 最小形状(method + id)。dialog 类:select/confirm/input/editor。 // fire-and-forget 类:notify/setStatus/...。 function dialogReq(id: string, method: "select" | "confirm" | "input" | "editor" = "select"): UiRequest { return { method, id, title: `q-${id}` }; } function fireAndForgetReq(id: string): UiRequest { return { method: "notify", id, message: `n-${id}` }; } // ── 测试 fixture ──────────────────────────────────────────── beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe("DialogGlobalQueue — FIFO 串行(TC-E4 case 1)", () => { it("3 个并发 dialog 请求按 FIFO 顺序串行处理(前一个 resolve 后才处理下一个)", async () => { const queue = new DialogGlobalQueue(); const callOrder: string[] = []; // 每个请求返回独立的可控 Promise,模拟 handler 延迟 const resolvers: Array<(v: UiResponse) => void> = []; const handler = vi.fn((req: UiRequest): Promise => { callOrder.push(req.id); return new Promise((resolve) => { resolvers.push(resolve); }); }); // 并发入队 3 个 dialog 请求 const p1 = queue.enqueue(dialogReq("r1"), handler); const p2 = queue.enqueue(dialogReq("r2"), handler); const p3 = queue.enqueue(dialogReq("r3"), handler); // 只有第一个立即被处理 expect(handler).toHaveBeenCalledTimes(1); expect(callOrder).toEqual(["r1"]); // resolve 第一个 → 第二个开始 resolvers[0]({ value: "a1" }); await vi.advanceTimersByTimeAsync(0); expect(handler).toHaveBeenCalledTimes(2); expect(callOrder).toEqual(["r1", "r2"]); // resolve 第二个 → 第三个开始 resolvers[1]({ value: "a2" }); await vi.advanceTimersByTimeAsync(0); expect(handler).toHaveBeenCalledTimes(3); expect(callOrder).toEqual(["r1", "r2", "r3"]); // resolve 第三个 → 全部 settle resolvers[2]({ value: "a3" }); await vi.advanceTimersByTimeAsync(0); const [r1, r2, r3] = await Promise.all([p1, p2, p3]); expect(r1).toEqual({ value: "a1" }); expect(r2).toEqual({ value: "a2" }); expect(r3).toEqual({ value: "a3" }); }); }); describe("DialogGlobalQueue — SR-4 child close reject(TC-E4 case 2)", () => { it("child close 时该 child 的 pending dialog reject 为 cancelled(防 Promise 永挂)", async () => { const queue = new DialogGlobalQueue(); const child = { pid: 20001 }; const handler = vi.fn( (): Promise => new Promise(() => {}), ); // 入队一个永远不 resolve 的 dialog,绑定 child const pending = queue.enqueue(dialogReq("r1"), handler, { child }); // 模拟 child close —— pending 必须被 reject 为 cancelled queue.rejectChildDialogs(child); await expect(pending).resolves.toEqual({ cancelled: true }); }); it("child close 不影响其他 child 的 pending dialog", async () => { const queue = new DialogGlobalQueue(); const childA = { pid: 30001 }; const childB = { pid: 30002 }; const handler = vi.fn( (): Promise => new Promise(() => {}), ); const pendingA = queue.enqueue(dialogReq("a1"), handler, { child: childA }); const pendingB = queue.enqueue(dialogReq("b1"), handler, { child: childB }); // close childA —— 只有 pendingA 被 reject,pendingB 仍 pending queue.rejectChildDialogs(childA); await expect(pendingA).resolves.toEqual({ cancelled: true }); // pendingB 不被 reject(仍 pending)—— 用 race + real timer 验证它没 settle。 // fake timer 下 setTimeout 不自动跑,临时切 real timer 做 10ms 超时探测。 vi.useRealTimers(); const settled = await Promise.race([ pendingB.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 10)), ]); expect(settled).toBe(false); vi.useFakeTimers(); }); }); describe("DialogGlobalQueue — handler 抛错兜底(TC-E4 case 3)", () => { it("handler 第一次抛错 → 回 cancelled → 队列继续处理下一个(不卡死)", async () => { const queue = new DialogGlobalQueue(); const callOrder: string[] = []; const handler = vi.fn((req: UiRequest): Promise => { callOrder.push(req.id); if (req.id === "r1") { return Promise.reject(new Error("handler boom")); } return Promise.resolve({ value: "ok-" + req.id }); }); const p1 = queue.enqueue(dialogReq("r1"), handler); const p2 = queue.enqueue(dialogReq("r2"), handler); await vi.advanceTimersByTimeAsync(0); // r1 抛错兜底回 cancelled(不向上抛) await expect(p1).resolves.toEqual({ cancelled: true }); // r2 正常处理 await expect(p2).resolves.toEqual({ value: "ok-r2" }); expect(callOrder).toEqual(["r1", "r2"]); }); }); describe("DialogGlobalQueue — fire-and-forget 防御性兼容(TC-E4 case 4)", () => { it("fire-and-forget method(notify)防御性直接调 handler,不串行等待(调用方约定不传此类)", async () => { const queue = new DialogGlobalQueue(); const callOrder: string[] = []; // 第一个 dialog 请求永不 resolve(占住队列) const blockingHandler = vi.fn( (): Promise => new Promise(() => {}), ); const ffHandler = vi.fn((req: UiRequest): Promise => { callOrder.push(req.id); return Promise.resolve({ ack: true }); }); // 入队一个 blocking dialog(占住队列) queue.enqueue(dialogReq("d1"), blockingHandler); // fire-and-forget 必须立即被调用,不等 d1 resolve const ffResult = await queue.enqueue(fireAndForgetReq("f1"), ffHandler); expect(ffHandler).toHaveBeenCalledTimes(1); expect(callOrder).toEqual(["f1"]); expect(ffResult).toEqual({ ack: true }); // blocking dialog 的 handler 仍只调用一次,ff 没有插入队列等待 expect(blockingHandler).toHaveBeenCalledTimes(1); }); }); // ── A4+C12:rejectAll + #19 单一推进点 + child close 集成语义 ── describe("DialogGlobalQueue — rejectAll settle 全部 pending + 清状态(#10/session_shutdown)", () => { it("rejectAll 把 current + 队列所有 pending settle 为 cancelled,并清空 queue/current/processing", async () => { const queue = new DialogGlobalQueue(); const child = { pid: 50001 }; const blocking = vi.fn((): Promise => new Promise(() => {})); // r1 占 current(永不 settle);r2/r3 在队列等(含跨 child 的 r3) const p1 = queue.enqueue(dialogReq("r1"), blocking, { child }); const p2 = queue.enqueue(dialogReq("r2"), blocking, { child }); const p3 = queue.enqueue(dialogReq("r3"), blocking); expect(queue.size).toBe(2); queue.rejectAll(); // 全部 settle 为 cancelled(Promise 不永挂) await expect(p1).resolves.toEqual({ cancelled: true }); await expect(p2).resolves.toEqual({ cancelled: true }); await expect(p3).resolves.toEqual({ cancelled: true }); // 状态清空 expect(queue.size).toBe(0); }); it("rejectAll 幂等:重复调用不抛错,状态保持清空,已 settle 的 Promise 不重复 resolve", async () => { const queue = new DialogGlobalQueue(); const blocking = vi.fn((): Promise => new Promise(() => {})); const p1 = queue.enqueue(dialogReq("r1"), blocking); queue.rejectAll(); await expect(p1).resolves.toEqual({ cancelled: true }); // 再次调用——幂等(settled 标志保证只 settle 一次),不抛错 expect(() => queue.rejectAll()).not.toThrow(); expect(queue.size).toBe(0); // p1 仍是 cancelled(防重复 resolve) await expect(p1).resolves.toEqual({ cancelled: true }); }); it("rejectAll 后队列仍可正常 enqueue 新请求(状态完全重置,processing 不残留)", async () => { const queue = new DialogGlobalQueue(); const blocking = vi.fn((): Promise => new Promise(() => {})); queue.enqueue(dialogReq("r1"), blocking); queue.rejectAll(); // rejectAll 后 enqueue 新 dialog,能正常处理(processing 已重置为 false) const okHandler = vi.fn(async (): Promise => ({ value: "new" })); const p = queue.enqueue(dialogReq("after"), okHandler); await vi.advanceTimersByTimeAsync(0); await expect(p).resolves.toEqual({ value: "new" }); expect(okHandler).toHaveBeenCalledTimes(1); }); }); describe("DialogGlobalQueue — #19 单一推进点 + child close 集成语义", () => { it("#19 单一推进点:rejectChildDialogs 取消占住 current 的永不 settle dialog 后,队列下一个 item 被推进处理(不卡死)", async () => { const queue = new DialogGlobalQueue(); const childA = { pid: 60001 }; const blockingHandler = vi.fn((): Promise => new Promise(() => {})); const r2Handler = vi.fn(async (): Promise => ({ value: "r2-ok" })); // r1 占 current(永不 settle,模拟等用户输入卡死);r2(另一 child)排队等待 const p1 = queue.enqueue(dialogReq("r1"), blockingHandler, { child: childA }); const p2 = queue.enqueue(dialogReq("r2"), r2Handler); expect(blockingHandler).toHaveBeenCalledTimes(1); expect(r2Handler).toHaveBeenCalledTimes(0); // r2 还在等 r1 settle // childA close → rejectChildDialogs settle r1;#19:settleItem 内唯一 processNext 推进 r2 queue.rejectChildDialogs(childA); await vi.advanceTimersByTimeAsync(0); await expect(p1).resolves.toEqual({ cancelled: true }); // 关键:r2 被推进(队列没卡死在永不 settle 的 r1) expect(r2Handler).toHaveBeenCalledTimes(1); await expect(p2).resolves.toEqual({ value: "r2-ok" }); }); it("child close 集成语义:queue 中同一 child 的多个 pending 被 rejectChildDialogs 一次全部 settle cancelled", async () => { // 模拟 session-runner child 'close' 事件 → dialogQueue.rejectChildDialogs(child) 清理路径。 // 绑定点在 session-runner(非 dialog-queue 内部),本测试验证 rejectChildDialogs 公共方法语义。 // 用 blocker 占 current(另一 child)使被测 child 的项全排队,聚焦验证 queue 批量 cancel。 const queue = new DialogGlobalQueue(); const blocker = { pid: 70000 }; const child = { pid: 70001 }; const blocking = vi.fn((): Promise => new Promise(() => {})); // blocker 占 current(永不 settle),让 child 的项都进队列 queue.enqueue(dialogReq("blk"), blocking, { child: blocker }); const p1 = queue.enqueue(dialogReq("c1"), blocking, { child }); const p2 = queue.enqueue(dialogReq("c2"), blocking, { child }); const p3 = queue.enqueue(dialogReq("c3"), blocking, { child }); expect(queue.size).toBe(3); queue.rejectChildDialogs(child); await expect(p1).resolves.toEqual({ cancelled: true }); await expect(p2).resolves.toEqual({ cancelled: true }); await expect(p3).resolves.toEqual({ cancelled: true }); expect(queue.size).toBe(0); }); });