@zhushanwen/pi-subagent-workflow 0.2.0 → 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/README.md +56 -0
- package/agents/{scout.md → explorer.md} +1 -1
- package/agents/orchestrator.md +48 -0
- package/package.json +1 -1
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
- package/src/execution/__tests__/crash-recovery.test.ts +5 -1
- package/src/execution/__tests__/dialog-queue.test.ts +299 -0
- package/src/execution/__tests__/execute-nesting.test.ts +1 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +22 -3
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
- package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
- package/src/execution/__tests__/host-mode.test.ts +87 -0
- package/src/execution/__tests__/index-session-start.test.ts +342 -0
- package/src/execution/__tests__/list-component.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +78 -0
- package/src/execution/__tests__/path-encoding.test.ts +30 -1
- package/src/execution/__tests__/record-store.test.ts +86 -2
- package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
- package/src/execution/__tests__/rpc-mode.test.ts +89 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
- package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
- package/src/execution/__tests__/spawn-args.test.ts +14 -19
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
- package/src/execution/__tests__/stdin-writer.test.ts +353 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/tool-action.test.ts +4 -4
- package/src/execution/__tests__/ui-channels.test.ts +187 -0
- package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
- package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
- package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
- package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
- package/src/execution/__tests__/worktree-manager.test.ts +1 -1
- package/src/execution/agent-registry.ts +1 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/execute-options-mapper.ts +5 -3
- package/src/execution/finalize-record.ts +160 -0
- package/src/execution/get-state-handshake.ts +104 -0
- package/src/execution/host-mode.ts +52 -0
- package/src/execution/manifest-store.ts +206 -0
- package/src/execution/notifier.ts +5 -1
- package/src/execution/path-encoding.ts +18 -0
- package/src/execution/pi-invocation.ts +1 -1
- package/src/execution/record-store.ts +108 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-runner.ts +216 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/subagent-service.ts +167 -197
- package/src/execution/types.ts +6 -6
- package/src/execution/ui-channels.ts +216 -0
- package/src/execution/ui-interaction-model.ts +48 -0
- package/src/execution/ui-request-handler-factory.ts +175 -0
- package/src/execution/ui-request-observability.ts +77 -0
- package/src/execution/ui-request-queue.ts +168 -0
- package/src/index.ts +90 -6
- package/src/interface/__tests__/detectors.test.ts +76 -0
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +27 -4
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +24 -2
- package/src/interface/format.ts +2 -0
- package/src/interface/subagent-actions.ts +21 -11
- package/src/interface/subagent-tool.ts +43 -10
- package/src/interface/tool-workflow.ts +58 -16
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// src/execution/__tests__/index-session-start.test.ts
|
|
2
|
+
//
|
|
3
|
+
// C2 critical: index.ts session_start 的 UI handler 注入链路测试。
|
|
4
|
+
//
|
|
5
|
+
// 测试目标:验证 `subagentsWorkflowExtension(pi)` 的 session_start handler
|
|
6
|
+
// 是否正确注入 uiRequestHandler,特别是 **SR-3**:
|
|
7
|
+
// 无论 new 还是 existing SubagentService,session_start 都必须调
|
|
8
|
+
// setUiRequestHandler——/resume /fork 复用 existingService 时旧 handler 可能失效。
|
|
9
|
+
//
|
|
10
|
+
// 4 个 case:
|
|
11
|
+
// 1. new 路径(existingService=null):setUiRequestHandler 被调,参数是函数(tui mode)
|
|
12
|
+
// 2. existing 路径(existingService=mockService):setUiRequestHandler 仍被调(SR-3 关键)
|
|
13
|
+
// 3. headless mode(json):createUiRequestHandlerForMode 返回 undefined,
|
|
14
|
+
// setUiRequestHandler(undefined) 被调(不注入但仍有调用,SR-3 形式不破)
|
|
15
|
+
// 4. rpc mode:setUiRequestHandler 被调,参数是函数
|
|
16
|
+
//
|
|
17
|
+
// 既有 crash-recovery.test.ts / session-start-reaper.test.ts mock 了 SubagentService
|
|
18
|
+
// 但**没有断言 setUiRequestHandler 被调用**——本测试补这个断言,且覆盖 existing 路径
|
|
19
|
+
// (既有测试 fixed getSubagentService() => null,只能走 new 分支)。
|
|
20
|
+
|
|
21
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
22
|
+
|
|
23
|
+
// ── mock modules(在 import 前声明;路径相对 src/execution/__tests/) ──
|
|
24
|
+
//
|
|
25
|
+
// [D15] 为何此处内联 vi.mock 与 vitest.config.ts 的 alias(包根 mocks/pi-coding-agent.ts)并存:
|
|
26
|
+
// 两套 mock 分工不同,不强制统一——
|
|
27
|
+
// 1. **config alias(包根 mocks/pi-coding-agent.ts)**:完整 stub,导出 ExtensionAPI/
|
|
28
|
+
// ExtensionContext/ExtensionMode 等 *类型*(编译期擦除)+ getAgentDir 运行时值。服务于
|
|
29
|
+
// 全仓库绝大多数测试的类型解析与轻量 mock(不 import 真实重模块的测试直接吃 alias)。
|
|
30
|
+
// 2. **本文件内联 vi.mock(下方)**:vi.mock 在运行时覆盖 config alias,确保本文件 import
|
|
31
|
+
// 真实 index.ts(它 `import { getAgentDir } from "@mariozechner/pi-coding-agent"` +
|
|
32
|
+
// 一组 type-only import)时,运行时只暴露 getAgentDir,彻底隔离真实 SDK 的模块顶层
|
|
33
|
+
// 副作用(避免 jiti 加载真实 pi 包触发未 mock 的依赖链)。
|
|
34
|
+
// 3. 形状一致性:内联 mock 的运行时值形状(`{ getAgentDir }`)与 alias stub 的运行时值
|
|
35
|
+
// 形状完全一致(alias stub 运行时也只导出 getAgentDir 函数,类型导出在运行期擦除)。
|
|
36
|
+
// 差异仅在「覆盖时机」——vi.mock 比 alias 更早介入模块图解析,保证真实 index.ts 加载时
|
|
37
|
+
// 拿到的是纯函数桩而非 alias 的完整对象。
|
|
38
|
+
// 4. crash-recovery.test.ts / session-start-reaper.test.ts 同此模式(import 真实模块的
|
|
39
|
+
// 测试统一用内联 vi.mock 覆盖),三者保持一致。
|
|
40
|
+
// 结论:不抽共享 mocks 文件。alias 已承担类型解析,内联 vi.mock 承担运行时隔离,职责正交。
|
|
41
|
+
|
|
42
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
43
|
+
getAgentDir: () => "/home/user/.pi/agent",
|
|
44
|
+
}));
|
|
45
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
46
|
+
getAgentDir: () => "/home/user/.pi/agent",
|
|
47
|
+
}));
|
|
48
|
+
vi.mock("@mariozechner/pi-ai", () => ({
|
|
49
|
+
StringEnum: (values: string[]) => ({ type: "string", enum: values }),
|
|
50
|
+
}));
|
|
51
|
+
vi.mock("@earendil-works/pi-ai", () => ({
|
|
52
|
+
StringEnum: (values: string[]) => ({ type: "string", enum: values }),
|
|
53
|
+
}));
|
|
54
|
+
vi.mock("@sinclair/typebox", () => ({
|
|
55
|
+
Type: {
|
|
56
|
+
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
|
|
57
|
+
Optional: (schema: unknown) => ({ ...(schema as object), optional: true }),
|
|
58
|
+
String: () => ({ type: "string" }),
|
|
59
|
+
Boolean: () => ({ type: "boolean" }),
|
|
60
|
+
Number: () => ({ type: "number" }),
|
|
61
|
+
Array: (items: unknown) => ({ type: "array", items }),
|
|
62
|
+
Record: (key: unknown, value: unknown) => ({ type: "object", additionalProperties: value, key }),
|
|
63
|
+
Unknown: () => ({ type: "unknown" }),
|
|
64
|
+
Union: (members: unknown[]) => ({ type: "union", members }),
|
|
65
|
+
Literal: (value: unknown) => ({ type: "literal", value }),
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
// ── hoisted mock 实例:捕获 setUiRequestHandler 调用 + 可控行为 ──
|
|
70
|
+
|
|
71
|
+
const {
|
|
72
|
+
mockSetUiRequestHandler,
|
|
73
|
+
mockInitSession,
|
|
74
|
+
mockLoadAll,
|
|
75
|
+
mockRecoverManifestTmpFiles,
|
|
76
|
+
/** existing service 引用——测试可改写以模拟 /resume /fork 复用。 */
|
|
77
|
+
existingServiceRef,
|
|
78
|
+
} = vi.hoisted(() => ({
|
|
79
|
+
mockSetUiRequestHandler: vi.fn(),
|
|
80
|
+
mockInitSession: vi.fn(),
|
|
81
|
+
mockLoadAll: vi.fn(async () => []),
|
|
82
|
+
// ADR-035 启动恢复接线守护:session_start 必须调 service.recoverManifestTmpFiles
|
|
83
|
+
mockRecoverManifestTmpFiles: vi.fn(async () => ({ deleted: 0, recovered: 0 })),
|
|
84
|
+
existingServiceRef: { current: null as unknown },
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
// SubagentService mock:每次构造都返回同一组 spy,setUiRequestHandler 可观察。
|
|
88
|
+
// getSubagentService 返回 existingServiceRef.current(null=走 new;非 null=走 existing)。
|
|
89
|
+
vi.mock("../subagent-service.ts", () => ({
|
|
90
|
+
SubagentService: class {
|
|
91
|
+
initSession = mockInitSession;
|
|
92
|
+
setUiRequestHandler = mockSetUiRequestHandler;
|
|
93
|
+
recoverManifestTmpFiles = mockRecoverManifestTmpFiles;
|
|
94
|
+
getStreamSink = () => null;
|
|
95
|
+
dispose = vi.fn();
|
|
96
|
+
},
|
|
97
|
+
getSubagentService: () => existingServiceRef.current,
|
|
98
|
+
setSubagentService: vi.fn(),
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
vi.mock("../model-config-service.ts", () => ({
|
|
102
|
+
ModelConfigService: class {
|
|
103
|
+
initModel = vi.fn();
|
|
104
|
+
getAgentRegistry = () => ({ get: () => undefined, list: () => [] });
|
|
105
|
+
setCtxModel = vi.fn();
|
|
106
|
+
},
|
|
107
|
+
getModelConfigService: () => null,
|
|
108
|
+
setModelConfigService: vi.fn(),
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
vi.mock("../worktree-manager.ts", () => ({
|
|
112
|
+
WorktreeManager: class {
|
|
113
|
+
constructor(_agentDir: string) {
|
|
114
|
+
/* mock */
|
|
115
|
+
}
|
|
116
|
+
scan = vi.fn();
|
|
117
|
+
cleanup = vi.fn();
|
|
118
|
+
create = vi.fn();
|
|
119
|
+
collectPatch = vi.fn();
|
|
120
|
+
registerPid = vi.fn();
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
vi.mock("../session-file-gc.ts", () => ({
|
|
125
|
+
maybeCleanupExpiredSessionFiles: vi.fn(),
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
// JsonlRunStore mock:loadAll 默认空数组(session_start 后段不抛即可)
|
|
129
|
+
vi.mock("../../orchestration/jsonl-run-store.ts", () => ({
|
|
130
|
+
JsonlRunStore: class {
|
|
131
|
+
loadAll = mockLoadAll;
|
|
132
|
+
save = vi.fn(async () => {});
|
|
133
|
+
},
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
// interface 层 mock:避免触发真实 pi.registerTool(pi 是 Proxy,真实模块访问 pi
|
|
137
|
+
// 属性时可能抛错)。路径相对 src/execution/__tests/ → ../../interface/...
|
|
138
|
+
vi.mock("../../interface/subagent-tool.ts", () => ({
|
|
139
|
+
registerSubagentTool: vi.fn(),
|
|
140
|
+
}));
|
|
141
|
+
vi.mock("../../interface/subagents.ts", () => ({
|
|
142
|
+
registerSubagentsCommand: vi.fn(),
|
|
143
|
+
}));
|
|
144
|
+
vi.mock("../../interface/bg-notify-render.ts", () => ({
|
|
145
|
+
renderBgNotifyMessage: vi.fn(),
|
|
146
|
+
}));
|
|
147
|
+
vi.mock("../../interface/tool-workflow.ts", () => ({
|
|
148
|
+
registerWorkflowTool: vi.fn(),
|
|
149
|
+
}));
|
|
150
|
+
vi.mock("../../interface/tool-workflow-script.ts", () => ({
|
|
151
|
+
registerWorkflowScriptTool: vi.fn(),
|
|
152
|
+
}));
|
|
153
|
+
vi.mock("../../interface/commands.ts", () => ({
|
|
154
|
+
registerWorkflowsCommand: vi.fn(),
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
// ── import 被测工厂 ──
|
|
158
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
159
|
+
|
|
160
|
+
import subagentsExtension from "../../index.ts";
|
|
161
|
+
|
|
162
|
+
// ── helpers ──
|
|
163
|
+
|
|
164
|
+
/** 创建可观察的 mock ExtensionAPI,捕获 session_start handler。
|
|
165
|
+
* Proxy 兜底:未显式处理的 pi.xxx 返回 noop,避免抛错。 */
|
|
166
|
+
function createMockPi(): {
|
|
167
|
+
pi: ExtensionAPI;
|
|
168
|
+
getSessionStartHandler: () => ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
|
|
169
|
+
} {
|
|
170
|
+
let sessionStartHandler: ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
|
|
171
|
+
const events = { emit: vi.fn() };
|
|
172
|
+
const noop = (): void => {
|
|
173
|
+
/* mock */
|
|
174
|
+
};
|
|
175
|
+
const pi = new Proxy<ExtensionAPI>({} as ExtensionAPI, {
|
|
176
|
+
get(_target, prop: string | symbol): unknown {
|
|
177
|
+
if (prop === "on") {
|
|
178
|
+
return (event: string, handler: (...args: unknown[]) => unknown) => {
|
|
179
|
+
if (event === "session_start") {
|
|
180
|
+
sessionStartHandler = handler as (event: unknown, ctx: unknown) => Promise<void>;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (prop === "events") return events;
|
|
185
|
+
if (prop === "appendEntry") return noop;
|
|
186
|
+
if (prop === "registerMessageRenderer") return noop;
|
|
187
|
+
return noop;
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
return { pi, getSessionStartHandler: () => sessionStartHandler };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 最小 ExtensionContext mock。mode 由参数控制(决定 handler 注入行为)。 */
|
|
194
|
+
function createMockCtx(mode: "tui" | "rpc" | "json" | "print"): Record<string, unknown> {
|
|
195
|
+
const sessionManager = {
|
|
196
|
+
getSessionId: () => "session-inject-1",
|
|
197
|
+
getSessionFile: () => "/home/user/.pi/agent/sessions/session-inject-1.jsonl",
|
|
198
|
+
getSessionDir: () => "/home/user/.pi/agent/sessions",
|
|
199
|
+
getCwd: () => "/home/user/project",
|
|
200
|
+
getEntries: () => [],
|
|
201
|
+
getBranch: () => [],
|
|
202
|
+
getLeafId: () => null,
|
|
203
|
+
getLeafEntry: () => undefined,
|
|
204
|
+
getEntry: () => undefined,
|
|
205
|
+
getHeader: () => null,
|
|
206
|
+
getTree: () => [],
|
|
207
|
+
getSessionName: () => undefined,
|
|
208
|
+
};
|
|
209
|
+
const ui = mode === "rpc" ? { setWidget: vi.fn() } : undefined;
|
|
210
|
+
return {
|
|
211
|
+
cwd: "/home/user/project",
|
|
212
|
+
mode,
|
|
213
|
+
modelRegistry: {
|
|
214
|
+
getAvailable: () => [],
|
|
215
|
+
find: () => undefined,
|
|
216
|
+
hasConfiguredAuth: () => false,
|
|
217
|
+
},
|
|
218
|
+
model: undefined,
|
|
219
|
+
sessionManager,
|
|
220
|
+
ui,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
beforeEach(() => {
|
|
225
|
+
vi.clearAllMocks();
|
|
226
|
+
mockLoadAll.mockResolvedValue([]);
|
|
227
|
+
existingServiceRef.current = null;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── tests ──
|
|
231
|
+
|
|
232
|
+
describe("session_start UI handler 注入链路(SR-3)", () => {
|
|
233
|
+
it("new 路径(existingService=null):setUiRequestHandler 被调,参数是函数(tui mode)", async () => {
|
|
234
|
+
const { pi, getSessionStartHandler } = createMockPi();
|
|
235
|
+
subagentsExtension(pi);
|
|
236
|
+
|
|
237
|
+
const handler = getSessionStartHandler();
|
|
238
|
+
expect(handler).toBeDefined();
|
|
239
|
+
await handler!({ type: "session_start" }, createMockCtx("tui"));
|
|
240
|
+
|
|
241
|
+
// SR-3 核心:setUiRequestHandler 必须被调
|
|
242
|
+
expect(mockSetUiRequestHandler).toHaveBeenCalledTimes(1);
|
|
243
|
+
// 非 headless mode → 注入的是函数(UiRequestHandler),非 undefined
|
|
244
|
+
const injected = mockSetUiRequestHandler.mock.calls[0]?.[0];
|
|
245
|
+
expect(typeof injected).toBe("function");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("existing 路径(existingService=mockService):setUiRequestHandler 仍被调(SR-3 关键)", async () => {
|
|
249
|
+
// 模拟 /resume /fork:getSubagentService() 返回已存在的 service
|
|
250
|
+
//(同一 mock 类的实例——spy 仍是 mockSetUiRequestHandler,断言可观察到调用)
|
|
251
|
+
existingServiceRef.current = {
|
|
252
|
+
initSession: mockInitSession,
|
|
253
|
+
setUiRequestHandler: mockSetUiRequestHandler,
|
|
254
|
+
// ADR-035:与 SubagentService mock class 形状一致——session_start
|
|
255
|
+
// 会调 service.recoverManifestTmpFiles(),缺方法会抛 TypeError 被吞为 console.warn
|
|
256
|
+
recoverManifestTmpFiles: mockRecoverManifestTmpFiles,
|
|
257
|
+
getStreamSink: () => null,
|
|
258
|
+
dispose: vi.fn(),
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const { pi, getSessionStartHandler } = createMockPi();
|
|
262
|
+
subagentsExtension(pi);
|
|
263
|
+
|
|
264
|
+
const handler = getSessionStartHandler();
|
|
265
|
+
expect(handler).toBeDefined();
|
|
266
|
+
await handler!({ type: "session_start" }, createMockCtx("tui"));
|
|
267
|
+
|
|
268
|
+
// SR-3 关键断言:existing 路径下 setUiRequestHandler 仍被调
|
|
269
|
+
//(旧 handler 可能已失效,session_start 必须重新注入)
|
|
270
|
+
expect(mockSetUiRequestHandler).toHaveBeenCalledTimes(1);
|
|
271
|
+
const injected = mockSetUiRequestHandler.mock.calls[0]?.[0];
|
|
272
|
+
expect(typeof injected).toBe("function");
|
|
273
|
+
|
|
274
|
+
// ADR-035 existing 路径守护:/resume /fork 复用 service 时 session_start
|
|
275
|
+
// 也必须调 recoverManifestTmpFiles。守护 case 走 new 路径抓不到 existing 误删,
|
|
276
|
+
// 此处对称断言(与 setUiRequestHandler 在本 case 的独立断言同模式)。
|
|
277
|
+
expect(mockRecoverManifestTmpFiles).toHaveBeenCalledTimes(1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("headless mode(json):createUiRequestHandlerForMode 返回 undefined → setUiRequestHandler(undefined)", async () => {
|
|
281
|
+
const { pi, getSessionStartHandler } = createMockPi();
|
|
282
|
+
subagentsExtension(pi);
|
|
283
|
+
|
|
284
|
+
const handler = getSessionStartHandler();
|
|
285
|
+
await handler!({ type: "session_start" }, createMockCtx("json"));
|
|
286
|
+
|
|
287
|
+
// SR-3 形式不破:仍调 setUiRequestHandler(参数是 undefined,表示不注入)
|
|
288
|
+
expect(mockSetUiRequestHandler).toHaveBeenCalledTimes(1);
|
|
289
|
+
expect(mockSetUiRequestHandler.mock.calls[0]?.[0]).toBeUndefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("rpc mode:setUiRequestHandler 被调,参数是函数", async () => {
|
|
293
|
+
const { pi, getSessionStartHandler } = createMockPi();
|
|
294
|
+
subagentsExtension(pi);
|
|
295
|
+
|
|
296
|
+
const handler = getSessionStartHandler();
|
|
297
|
+
await handler!({ type: "session_start" }, createMockCtx("rpc"));
|
|
298
|
+
|
|
299
|
+
expect(mockSetUiRequestHandler).toHaveBeenCalledTimes(1);
|
|
300
|
+
const injected = mockSetUiRequestHandler.mock.calls[0]?.[0];
|
|
301
|
+
expect(typeof injected).toBe("function");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("initSession 不再收 uiRequestHandler(#24 单一注入入口:setUiRequestHandler 唯一通道)", async () => {
|
|
305
|
+
const { pi, getSessionStartHandler } = createMockPi();
|
|
306
|
+
subagentsExtension(pi);
|
|
307
|
+
|
|
308
|
+
const handler = getSessionStartHandler();
|
|
309
|
+
await handler!({ type: "session_start" }, createMockCtx("tui"));
|
|
310
|
+
|
|
311
|
+
// [#24] uiRequestHandler 单一注入入口:index.ts 只调 service.setUiRequestHandler(h),
|
|
312
|
+
// 不再重复传 initSession.uiRequestHandler——避免同一 handler 双路径注入造成
|
|
313
|
+
// "哪一个是 source of truth" 歧义。此处断言该契约不被回退。
|
|
314
|
+
expect(mockInitSession).toHaveBeenCalledTimes(1);
|
|
315
|
+
const initArg = mockInitSession.mock.calls[0]?.[0] as {
|
|
316
|
+
uiRequestHandler?: unknown;
|
|
317
|
+
mode?: unknown;
|
|
318
|
+
dialogQueue?: unknown;
|
|
319
|
+
} | undefined;
|
|
320
|
+
expect(initArg).toBeDefined();
|
|
321
|
+
// uiRequestHandler 不再走 initSession(已被 #24 移除)
|
|
322
|
+
expect(initArg?.uiRequestHandler).toBeUndefined();
|
|
323
|
+
// mode 仍需 session 级注入(uiObservability.setMode 依赖它)
|
|
324
|
+
expect(initArg?.mode).toBe("tui");
|
|
325
|
+
// dialogQueue 仍注入(SR-4 清理路径接通)
|
|
326
|
+
expect(initArg).toHaveProperty("dialogQueue");
|
|
327
|
+
// handler 经 setUiRequestHandler 注入(单一入口)——前面 case 已断言,此处复断不再赘述。
|
|
328
|
+
expect(mockSetUiRequestHandler).toHaveBeenCalledTimes(1);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("session_start 调用 recoverManifestTmpFiles(接线守护,防 ADR-035 启动恢复再次断线)", async () => {
|
|
332
|
+
const { pi, getSessionStartHandler } = createMockPi();
|
|
333
|
+
subagentsExtension(pi);
|
|
334
|
+
|
|
335
|
+
const handler = getSessionStartHandler();
|
|
336
|
+
expect(handler).toBeDefined();
|
|
337
|
+
await handler!({ type: "session_start" }, createMockCtx("tui"));
|
|
338
|
+
|
|
339
|
+
// ADR-035 接线断言:session_start 必须调 service.recoverManifestTmpFiles(防死代码回退)
|
|
340
|
+
expect(mockRecoverManifestTmpFiles).toHaveBeenCalledTimes(1);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { afterEach,beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
14
|
|
|
15
|
-
import type { SubagentService } from "../subagent-service.ts";
|
|
16
15
|
import type { ThemeLike } from "../../interface/format.ts";
|
|
17
16
|
import { SubagentsListComponent } from "../../interface/list-component.ts";
|
|
18
17
|
import type { KeyHandler, KeyResult, TuiLike, ViewState } from "../../interface/list-shared.ts";
|
|
18
|
+
import type { SubagentService } from "../subagent-service.ts";
|
|
19
19
|
import type { SubagentRecord } from "../types.ts";
|
|
20
20
|
|
|
21
21
|
// ── KeyResult 常量(语义清晰,避到处写字面量对象) ──
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BgNotifier flushPendingNotifications — deliverAs 契约(FR-3/AC-3)。
|
|
3
|
+
*
|
|
4
|
+
* 修复:deliverAs 从 'followUp' 改为 'steer',确保 subagent 完成通知在主 agent
|
|
5
|
+
* 处于 processing 状态(如轮询 loop)时也能立即抢占下一个 turn。
|
|
6
|
+
*
|
|
7
|
+
* 修复背景:commit d214d0d83 已验证 steer 能避免 'Agent is already processing';
|
|
8
|
+
* workflow helpers.ts:151 已在同语义下用 steer。
|
|
9
|
+
*
|
|
10
|
+
* 测试方法:mock NotifierHost,捕获 sendMessage 调用参数,断言 deliverAs === 'steer'。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { BgNotifier, type NotifierHost } from "../notifier.ts";
|
|
16
|
+
|
|
17
|
+
/** mock host:捕获所有 sendMessage 调用 + 控制 hasRunningBackground。 */
|
|
18
|
+
function makeMockHost(): NotifierHost & {
|
|
19
|
+
sendMessageCalls: { message: unknown; options: unknown }[];
|
|
20
|
+
hasRunningBackground: ReturnType<typeof vi.fn>;
|
|
21
|
+
} {
|
|
22
|
+
const sendMessageCalls: { message: unknown; options: unknown }[] = [];
|
|
23
|
+
const hasRunningBackground = vi.fn(() => false);
|
|
24
|
+
return {
|
|
25
|
+
sendMessageCalls,
|
|
26
|
+
hasRunningBackground,
|
|
27
|
+
sendMessage(message, options) {
|
|
28
|
+
sendMessageCalls.push({ message, options });
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
|
|
34
|
+
let host: ReturnType<typeof makeMockHost>;
|
|
35
|
+
let notifier: BgNotifier;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
host = makeMockHost();
|
|
39
|
+
notifier = new BgNotifier(host);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
notifier.dispose();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("flush 时调 sendMessage 的 options.deliverAs === 'steer'(FR-3/AC-3)", () => {
|
|
47
|
+
notifier.notify({
|
|
48
|
+
id: "bg-test-1",
|
|
49
|
+
status: "done",
|
|
50
|
+
agent: "explorer",
|
|
51
|
+
result: "done",
|
|
52
|
+
startedAt: Date.now() - 1000,
|
|
53
|
+
endedAt: Date.now(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// hasRunningBackground=false → notify 立即 flush
|
|
57
|
+
expect(host.sendMessageCalls).toHaveLength(1);
|
|
58
|
+
const call = host.sendMessageCalls[0];
|
|
59
|
+
expect(call.options).toMatchObject({ deliverAs: "steer" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("flush 时 triggerTurn 也必须为 true(让父 agent 立即唤醒)", () => {
|
|
63
|
+
notifier.notify({
|
|
64
|
+
id: "bg-test-2",
|
|
65
|
+
status: "failed",
|
|
66
|
+
agent: "worker",
|
|
67
|
+
error: "boom",
|
|
68
|
+
startedAt: Date.now(),
|
|
69
|
+
endedAt: Date.now(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(host.sendMessageCalls).toHaveLength(1);
|
|
73
|
+
expect(host.sendMessageCalls[0].options).toMatchObject({
|
|
74
|
+
triggerTurn: true,
|
|
75
|
+
deliverAs: "steer",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -6,7 +6,7 @@ import * as path from "node:path";
|
|
|
6
6
|
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
8
|
|
|
9
|
-
import { encodeCwd, getSubagentSessionDir } from "../path-encoding.ts";
|
|
9
|
+
import { encodeCwd, getSubagentRecordsDir, getSubagentSessionDir } from "../path-encoding.ts";
|
|
10
10
|
|
|
11
11
|
describe("encodeCwd", () => {
|
|
12
12
|
it("encodes a normal unix absolute path", () => {
|
|
@@ -73,3 +73,32 @@ describe("getSubagentSessionDir", () => {
|
|
|
73
73
|
expect(a).not.toBe(b);
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
describe("getSubagentRecordsDir", () => {
|
|
78
|
+
it("returns agentDir/subagents/<encodedCwd>/records", () => {
|
|
79
|
+
const result = getSubagentRecordsDir("/home/user/.pi/agent", "/Users/x/proj");
|
|
80
|
+
expect(result).toBe(
|
|
81
|
+
path.join("/home/user/.pi/agent", "subagents", "--Users-x-proj--", "records")
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("shares the same <enc> segment with getSubagentSessionDir for same cwd", () => {
|
|
86
|
+
// F1:records 与 sessions 在同一 <enc> 段下,仅尾段不同(records vs sessions)。
|
|
87
|
+
// worktree 场景三者恒等的前提就靠这个共享 enc。
|
|
88
|
+
const agentDir = "/home/user/.pi/agent";
|
|
89
|
+
const cwd = "/Users/x/proj";
|
|
90
|
+
const sessions = getSubagentSessionDir(agentDir, cwd);
|
|
91
|
+
const records = getSubagentRecordsDir(agentDir, cwd);
|
|
92
|
+
// 共享 enc:去掉尾段后剩同一路径前缀
|
|
93
|
+
expect(path.dirname(sessions)).toBe(path.dirname(records));
|
|
94
|
+
// 尾段不同
|
|
95
|
+
expect(path.basename(sessions)).toBe("sessions");
|
|
96
|
+
expect(path.basename(records)).toBe("records");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("produces different path for different mainCwd", () => {
|
|
100
|
+
const a = getSubagentRecordsDir("/agent", "/cwd1");
|
|
101
|
+
const b = getSubagentRecordsDir("/agent", "/cwd2");
|
|
102
|
+
expect(a).not.toBe(b);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -15,11 +15,12 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
|
|
18
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
19
|
|
|
20
|
-
import { createRecord } from "../execution-record.ts";
|
|
21
20
|
import { writeAliveMarker } from "../alive-store.ts";
|
|
21
|
+
import { createRecord } from "../execution-record.ts";
|
|
22
22
|
import { writeFinalized } from "../finalized-marker.ts";
|
|
23
|
+
import type { ManifestRecord, ManifestStore } from "../manifest-store.ts";
|
|
23
24
|
import type { StatusFilter } from "../record-store.ts";
|
|
24
25
|
import { RecordStore } from "../record-store.ts";
|
|
25
26
|
import { writeCancelledTombstone } from "../tombstone-store.ts";
|
|
@@ -542,4 +543,87 @@ describe("RecordStore", () => {
|
|
|
542
543
|
expect(found?.endedAt).toBeUndefined();
|
|
543
544
|
});
|
|
544
545
|
});
|
|
546
|
+
|
|
547
|
+
// ============================================================
|
|
548
|
+
// [BL-M1] 修复:损坏 manifest 上报 pi.appendEntry(用户可见)
|
|
549
|
+
// collectRecords 跳过 mapManifestStatus 返回 null 的 manifest 时,除 console.warn
|
|
550
|
+
// 外还需调 pi.appendEntry("subagent:manifest-invalid-status", ...) 让用户能在 session
|
|
551
|
+
// 内复盘事故——避免磁盘陈旧/损坏 manifest 被静默吞掉。
|
|
552
|
+
// ============================================================
|
|
553
|
+
describe("manifest status 越界(损坏 manifest)", () => {
|
|
554
|
+
/** 构造一个 status 越界的 manifest mock。
|
|
555
|
+
* ManifestRecord.status union 是 4 态,运行时却可能收到陈旧 "error" / 意外 "crashed"
|
|
556
|
+
* 等越界值——isValidManifest 当前挡掉了大部分,但 manifestStore.listAllSync 与
|
|
557
|
+
* mapManifestStatus 之间仍有防御层([BL-M1] 修复记录)。本测试用 unknown-cast 模拟
|
|
558
|
+
* 一个越过 ManifestRecord 静态类型的对象,验证 collectRecords 反应正确。 */
|
|
559
|
+
function brokenManifest(): ManifestRecord {
|
|
560
|
+
// eslint-disable-next-line taste/no-unsafe-cast -- intentional: cast status 越界值模拟磁盘损坏 manifest(测试目的本身)
|
|
561
|
+
return {
|
|
562
|
+
id: "broken-1",
|
|
563
|
+
rootSessionId: "sess-current",
|
|
564
|
+
agentName: "worker",
|
|
565
|
+
status: "crashed", // 越界值(union 不含 "crashed")
|
|
566
|
+
createdAt: 1000,
|
|
567
|
+
} as unknown as ManifestRecord;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Mock ManifestStore 让测试不依赖 fs 路径 + isValidManifest 守卫。
|
|
571
|
+
* source/target 类型不兼容是 intentional(ManifestStore 有多个方法,测试仅需 listAllSync),
|
|
572
|
+
* `as unknown as ManifestStore` 是 vitest mock 标准 pattern。*/
|
|
573
|
+
function makeManifestStoreMock(manifests: ManifestRecord[]): ManifestStore {
|
|
574
|
+
// eslint-disable-next-line taste/no-unsafe-cast -- intentional: ManifestStore 有多个方法,测试仅需 listAllSync
|
|
575
|
+
return {
|
|
576
|
+
// 复制 manifests 防调用方意外 mutate 共享数组;vi.fn 每次调用返回独立数组
|
|
577
|
+
listAllSync: vi.fn(() => [...manifests]),
|
|
578
|
+
} as unknown as ManifestStore;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
it("collectRecords 跳过越界 status 时调用 pi.appendEntry('subagent:manifest-invalid-status', ...)", () => {
|
|
582
|
+
const pi = { appendEntry: vi.fn() };
|
|
583
|
+
// Mock ManifestStore:listAllSync 返回损坏 manifest,绕开真实 fs 路径与 isValidManifest 守卫。
|
|
584
|
+
const manifestStore = makeManifestStoreMock([brokenManifest()]);
|
|
585
|
+
const store = new RecordStore(tmpDir, manifestStore, pi);
|
|
586
|
+
|
|
587
|
+
const result = store.collectRecords(100);
|
|
588
|
+
|
|
589
|
+
// 损坏 manifest 不出现在结果中(status 越界被跳过,不降级为 failed——避免误告警)
|
|
590
|
+
expect(result.find((r) => r.id === "broken-1")).toBeUndefined();
|
|
591
|
+
// appendEntry 被调用,customType + data 字段正确
|
|
592
|
+
expect(pi.appendEntry).toHaveBeenCalledTimes(1);
|
|
593
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("subagent:manifest-invalid-status", {
|
|
594
|
+
id: "broken-1",
|
|
595
|
+
status: "crashed",
|
|
596
|
+
rootSessionId: "sess-current",
|
|
597
|
+
agentName: "worker",
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("pi 为 null/undefined 时不抛错(向后兼容:不注入 pi 的旧调用路径)", () => {
|
|
602
|
+
const manifestStore = makeManifestStoreMock([brokenManifest()]);
|
|
603
|
+
// 不传 pi(undefined)—— 模拟 SubagentService 构造期 session_start 未触发的场景。
|
|
604
|
+
const store = new RecordStore(tmpDir, manifestStore);
|
|
605
|
+
expect(() => store.collectRecords(100)).not.toThrow();
|
|
606
|
+
// 损坏 manifest 仍被跳过(不降级)
|
|
607
|
+
const result = store.collectRecords(100);
|
|
608
|
+
expect(result.find((r) => r.id === "broken-1")).toBeUndefined();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("setPi() 后 appendEntry 切换到新 pi(覆盖初始 undefined)", () => {
|
|
612
|
+
const pi1 = { appendEntry: vi.fn() };
|
|
613
|
+
const pi2 = { appendEntry: vi.fn() };
|
|
614
|
+
const manifestStore = makeManifestStoreMock([brokenManifest()]);
|
|
615
|
+
const store = new RecordStore(tmpDir, manifestStore, pi1);
|
|
616
|
+
store.setPi(pi2);
|
|
617
|
+
|
|
618
|
+
store.collectRecords(100);
|
|
619
|
+
|
|
620
|
+
// 切换后只调 pi2(pi1 不再被调用)
|
|
621
|
+
expect(pi1.appendEntry).not.toHaveBeenCalled();
|
|
622
|
+
expect(pi2.appendEntry).toHaveBeenCalledTimes(1);
|
|
623
|
+
expect(pi2.appendEntry).toHaveBeenCalledWith(
|
|
624
|
+
"subagent:manifest-invalid-status",
|
|
625
|
+
expect.objectContaining({ id: "broken-1" }),
|
|
626
|
+
);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
545
629
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/__tests__/records-cwd-isolation.test.ts
|
|
2
|
+
//
|
|
3
|
+
// T5(F1 端到端补充):验证 records 目录改 cwd-scoped 后,不同 cwd 的 manifest 物理
|
|
4
|
+
// 隔离——一个 cwd 的 ManifestStore 读不到另一个 cwd 的 manifest。
|
|
5
|
+
//
|
|
6
|
+
// F1 之前 recordsDir = path.join(agentDir, "records")(全局共享),所有 cwd 的 manifest
|
|
7
|
+
// 混在一个目录,靠 rootSessionFilter 软隔离。F1 改为 getSubagentRecordsDir(agentDir, cwd)
|
|
8
|
+
// 后,每个 cwd 物理独立目录,不再依赖软过滤。
|
|
9
|
+
//
|
|
10
|
+
// T1(path-encoding.test.ts)已覆盖路径层契约;本测试补一个真实 fs + ManifestStore 的
|
|
11
|
+
// 端到端隔离烟雾测试,避免 SubagentService 构造成本(需 mock ModelConfigService/PiLike 等)。
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
17
|
+
|
|
18
|
+
import type { ManifestRecord } from "../manifest-store.ts";
|
|
19
|
+
import { ManifestStore } from "../manifest-store.ts";
|
|
20
|
+
import { getSubagentRecordsDir } from "../path-encoding.ts";
|
|
21
|
+
|
|
22
|
+
let tmpAgentDir: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tmpAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "records-cwd-iso-"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
fs.rmSync(tmpAgentDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** 写一个最小合法的 manifest 到指定 records 目录。 */
|
|
33
|
+
function writeManifest(recordsDir: string, id: string): string {
|
|
34
|
+
fs.mkdirSync(recordsDir, { recursive: true });
|
|
35
|
+
const rec: ManifestRecord = {
|
|
36
|
+
id,
|
|
37
|
+
rootSessionId: "sess-1",
|
|
38
|
+
agentName: "worker",
|
|
39
|
+
status: "completed",
|
|
40
|
+
createdAt: Date.now(),
|
|
41
|
+
};
|
|
42
|
+
const filePath = path.join(recordsDir, `${id}.json`);
|
|
43
|
+
fs.writeFileSync(filePath, JSON.stringify(rec), "utf-8");
|
|
44
|
+
return filePath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("[F1/T5] records cwd physical isolation", () => {
|
|
48
|
+
it("ManifestStore bound to cwd1 cannot read cwd2's manifest (physical isolation)", () => {
|
|
49
|
+
const cwd1 = "/Users/x/proj-a";
|
|
50
|
+
const cwd2 = "/Users/x/proj-b";
|
|
51
|
+
const recordsDir1 = getSubagentRecordsDir(tmpAgentDir, cwd1);
|
|
52
|
+
const recordsDir2 = getSubagentRecordsDir(tmpAgentDir, cwd2);
|
|
53
|
+
|
|
54
|
+
// 两个 records 目录必须物理不同(已在 T1 路径层覆盖,这里再断言一次作为前提)
|
|
55
|
+
expect(recordsDir1).not.toBe(recordsDir2);
|
|
56
|
+
|
|
57
|
+
// 只在 cwd1 的 records 目录写一个 manifest
|
|
58
|
+
writeManifest(recordsDir1, "rec-cwd1");
|
|
59
|
+
|
|
60
|
+
const store1 = new ManifestStore(recordsDir1);
|
|
61
|
+
const store2 = new ManifestStore(recordsDir2);
|
|
62
|
+
|
|
63
|
+
const cwd1Ids = store1.listAllSync().map((m) => m.id);
|
|
64
|
+
const cwd2Ids = store2.listAllSync().map((m) => m.id);
|
|
65
|
+
|
|
66
|
+
// cwd1 能读到自己的 manifest
|
|
67
|
+
expect(cwd1Ids).toContain("rec-cwd1");
|
|
68
|
+
// cwd2 物理读不到 cwd1 的 manifest(不再依赖 rootSessionFilter 软过滤)
|
|
69
|
+
expect(cwd2Ids).not.toContain("rec-cwd1");
|
|
70
|
+
expect(cwd2Ids).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("two cwds with their own manifests are mutually invisible", () => {
|
|
74
|
+
const cwdA = "/home/user/project-a";
|
|
75
|
+
const cwdB = "/home/user/project-b";
|
|
76
|
+
const recordsDirA = getSubagentRecordsDir(tmpAgentDir, cwdA);
|
|
77
|
+
const recordsDirB = getSubagentRecordsDir(tmpAgentDir, cwdB);
|
|
78
|
+
|
|
79
|
+
writeManifest(recordsDirA, "rec-a");
|
|
80
|
+
writeManifest(recordsDirB, "rec-b");
|
|
81
|
+
|
|
82
|
+
const storeA = new ManifestStore(recordsDirA);
|
|
83
|
+
const storeB = new ManifestStore(recordsDirB);
|
|
84
|
+
|
|
85
|
+
const aIds = storeA.listAllSync().map((m) => m.id);
|
|
86
|
+
const bIds = storeB.listAllSync().map((m) => m.id);
|
|
87
|
+
|
|
88
|
+
expect(aIds).toEqual(["rec-a"]);
|
|
89
|
+
expect(bIds).toEqual(["rec-b"]);
|
|
90
|
+
});
|
|
91
|
+
});
|