@zhushanwen/pi-subagent-workflow 8.3.0 → 8.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.
Files changed (103) hide show
  1. package/package.json +18 -4
  2. package/relay/relay.mjs +390 -0
  3. package/skills/subagent-ext-config/SKILL.md +80 -0
  4. package/src/execution/__tests__/agent-registry.test.ts +110 -0
  5. package/src/execution/__tests__/chat-engine-routing.test.ts +597 -0
  6. package/src/execution/__tests__/execution-record.test.ts +127 -1
  7. package/src/execution/__tests__/pi-invocation.test.ts +62 -1
  8. package/src/execution/__tests__/relay-agent.test.ts +448 -0
  9. package/src/execution/__tests__/relay-env.test.ts +42 -0
  10. package/src/execution/__tests__/startup-config-declaration.test.ts +35 -0
  11. package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
  12. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
  13. package/src/execution/__tests__/subprocess-agent-runner.test.ts +53 -5
  14. package/src/execution/agent-registry.ts +10 -0
  15. package/src/execution/config.ts +25 -2
  16. package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
  17. package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
  18. package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
  19. package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
  20. package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
  21. package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
  22. package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
  23. package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
  24. package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
  25. package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
  26. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
  27. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
  28. package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
  29. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
  30. package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
  31. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
  32. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
  33. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
  34. package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
  35. package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
  36. package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
  37. package/src/execution/engine/__tests__/paths.test.ts +39 -0
  38. package/src/execution/engine/__tests__/registry.test.ts +120 -0
  39. package/src/execution/engine/__tests__/routing.test.ts +231 -0
  40. package/src/execution/engine/common/data-dir.ts +62 -0
  41. package/src/execution/engine/common/errors.ts +183 -0
  42. package/src/execution/engine/common/event-journal.ts +254 -0
  43. package/src/execution/engine/common/journal-replay.ts +62 -0
  44. package/src/execution/engine/common/kill-chain.ts +221 -0
  45. package/src/execution/engine/common/nesting-guard.ts +50 -0
  46. package/src/execution/engine/common/persona-router.ts +108 -0
  47. package/src/execution/engine/common/pool-manager.ts +226 -0
  48. package/src/execution/engine/common/schema-emulation.ts +189 -0
  49. package/src/execution/engine/common/session-view-projection.ts +51 -0
  50. package/src/execution/engine/engine-discovery.ts +65 -0
  51. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
  52. package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
  53. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
  54. package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
  55. package/src/execution/engine/engines/pi/reader.ts +48 -0
  56. package/src/execution/engine/engines/pi/registration.ts +35 -0
  57. package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
  58. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
  59. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
  60. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
  61. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
  62. package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
  63. package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
  64. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
  65. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +567 -0
  66. package/src/execution/engine/engines/zcode/constants.ts +43 -0
  67. package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
  68. package/src/execution/engine/engines/zcode/launcher.ts +161 -0
  69. package/src/execution/engine/engines/zcode/parser.ts +436 -0
  70. package/src/execution/engine/engines/zcode/preparer.ts +363 -0
  71. package/src/execution/engine/engines/zcode/reader.ts +381 -0
  72. package/src/execution/engine/engines/zcode/registration.ts +37 -0
  73. package/src/execution/engine/engines/zcode/zcode-engine.ts +648 -0
  74. package/src/execution/engine/host-task-spec.ts +47 -0
  75. package/src/execution/engine/model-prompt.ts +59 -0
  76. package/src/execution/engine/paths.ts +42 -0
  77. package/src/execution/engine/port.ts +153 -0
  78. package/src/execution/engine/registry.ts +123 -0
  79. package/src/execution/engine/routing.ts +218 -0
  80. package/src/execution/engine/types.ts +304 -0
  81. package/src/execution/execute-options-mapper.ts +5 -1
  82. package/src/execution/execution-record.ts +6 -0
  83. package/src/execution/model-resolver.ts +6 -0
  84. package/src/execution/pi-invocation.ts +32 -2
  85. package/src/execution/record-entry.ts +14 -0
  86. package/src/execution/record-store.ts +34 -0
  87. package/src/execution/relay-env.ts +37 -0
  88. package/src/execution/session-runner.ts +24 -0
  89. package/src/execution/stream-sink.ts +26 -0
  90. package/src/execution/subagent-service.ts +249 -11
  91. package/src/execution/subprocess-agent-runner.ts +196 -14
  92. package/src/execution/types.ts +56 -0
  93. package/src/index.ts +46 -1
  94. package/src/interface/command-actions.ts +72 -8
  95. package/src/interface/subagent-actions.ts +8 -2
  96. package/src/interface/subagent-tool.ts +6 -0
  97. package/src/interface/subagents.ts +198 -30
  98. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +5 -2
  99. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +3 -3
  100. package/src/orchestration/models/types.ts +7 -0
  101. package/src/orchestration/worker-script-builder.ts +5 -2
  102. package/src/shared/meta-parser.ts +5 -1
  103. package/src/shared/resource-meta.ts +5 -0
@@ -13,9 +13,14 @@
13
13
  // T3.18 (NFR): dispose 兜底覆盖(delegate 后子进程进 spawnedChildren)
14
14
  // T3.19 (NFR): AgentCallOpts→ExecuteOptions 映射保真
15
15
 
16
+ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+
16
20
  import { describe, expect, it, vi } from "vitest";
17
21
 
18
22
  import type { AgentCallOpts, AgentResult } from "../../orchestration/models/types.ts";
23
+ import { replayJournal } from "../engine/common/event-journal.ts";
19
24
  import type { SubprocessAgentRunnerDeps } from "../subprocess-agent-runner.ts";
20
25
  import { SubprocessAgentRunner } from "../subprocess-agent-runner.ts";
21
26
 
@@ -284,12 +289,12 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
284
289
  });
285
290
  });
286
291
 
287
- it("无 workflow onEvent → 不传 bridgedOnEvent", async () => {
288
- let bridgedOnEvent: ((e: Record<string, unknown>) => void) | undefined;
292
+ it("无 workflow onEvent → 引擎仍收到包装 onEvent(P2 journal 通道恒开)", async () => {
293
+ let engineOnEvent: ((e: Record<string, unknown>) => void) | undefined;
289
294
  const mockService = createMockService(
290
295
  vi.fn().mockImplementation(
291
296
  (_opts: Record<string, unknown>, _signal?: AbortSignal, onEvent?: (e: Record<string, unknown>) => void) => {
292
- bridgedOnEvent = onEvent;
297
+ engineOnEvent = onEvent;
293
298
  return Promise.resolve(makeMockResult());
294
299
  },
295
300
  ),
@@ -299,8 +304,51 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
299
304
 
300
305
  await sar.run(makeBaseOpts(), new AbortController().signal);
301
306
 
302
- // 不传 workflow onEvent bridgedOnEvent 应为 undefined
303
- expect(bridgedOnEvent).toBeUndefined();
307
+ // P2 接线(设计 D6 第②级):SAR 包装 onEvent journal 后转发——原 onEvent
308
+ // 未传时也恒传包装版(全引擎免费获得 journal;P1「undefined 透传」细节已被
309
+ // 有意变更,本用例锁定新语义:引擎侧 onEvent 通道恒开)
310
+ expect(typeof engineOnEvent).toBe("function");
311
+ // 包装版不向外抛(journal append 是同步入队)
312
+ expect(() => engineOnEvent?.({ type: "turn_end" })).not.toThrow();
313
+ });
314
+
315
+ it("P2 journal 接线:run 期间事件落盘 journal-<taskId>.jsonl(中立格式,可重放)", async () => {
316
+ vi.useFakeTimers({ toFake: [] }); // 真实 timers:journal 写盘走真实 fs
317
+ const dataDir = mkdtempSync(join(tmpdir(), "sar-journal-test-"));
318
+ const prevEnv = process.env.XYZ_AGENT_DATA_DIR;
319
+ process.env.XYZ_AGENT_DATA_DIR = dataDir;
320
+ try {
321
+ const mockService = createMockService(
322
+ vi.fn().mockImplementation(
323
+ (_opts: Record<string, unknown>, _signal?: AbortSignal, onEvent?: (e: Record<string, unknown>) => void) => {
324
+ onEvent?.({ type: "tool_start", toolName: "bash", args: { cmd: "ls" } });
325
+ onEvent?.({ type: "turn_end" });
326
+ return Promise.resolve(makeMockResult());
327
+ },
328
+ ),
329
+ );
330
+ const deps: SubprocessAgentRunnerDeps = { subagentService: mockService };
331
+ const sar = new SubprocessAgentRunner(deps);
332
+
333
+ const workflowOnEvent = vi.fn();
334
+ await sar.run(makeBaseOpts(), new AbortController().signal, workflowOnEvent);
335
+
336
+ // journal 落在 <dataDir>/engines/pi/shared/journal-<taskId>.jsonl
337
+ const journalFile = join(dataDir, "engines", "pi", "shared");
338
+ const files = readdirSync(journalFile).filter((f) => f.startsWith("journal-") && f.endsWith(".jsonl"));
339
+ expect(files.length).toBe(1);
340
+ const replayed = replayJournal(join(journalFile, files[0] ?? ""));
341
+ expect(replayed).toEqual([
342
+ { type: "tool_start", toolName: "bash", args: { cmd: "ls" } },
343
+ { type: "turn_end" },
344
+ ]);
345
+ // workflow onEvent 照常透传(journal 包装不吞事件)
346
+ expect(workflowOnEvent).toHaveBeenCalledTimes(2);
347
+ } finally {
348
+ if (prevEnv === undefined) delete process.env.XYZ_AGENT_DATA_DIR;
349
+ else process.env.XYZ_AGENT_DATA_DIR = prevEnv;
350
+ rmSync(dataDir, { recursive: true, force: true });
351
+ }
304
352
  });
305
353
  });
306
354
 
@@ -20,6 +20,7 @@ import { parseResourceMeta } from "../shared/meta-parser.ts";
20
20
  import { lintAgentMeta } from "../orchestration/script-lint.ts";
21
21
  import type { AgentMeta } from "../shared/resource-meta.ts";
22
22
  import type { AgentConfig } from "./model-resolver.ts";
23
+ import { hasEngine, listEngines, EngineNotFoundError } from "./engine/registry.ts";
23
24
 
24
25
  const logger = getLogger("subagents");
25
26
 
@@ -100,6 +101,14 @@ export function parseAgentWithMeta(
100
101
  );
101
102
  }
102
103
  const defaultBackgroundRaw = extractYamlField(yamlBlock, "defaultBackground");
104
+ // engine 字段(D9):结构化优先(IF1),legacy fallback 与 model/tools 同判——
105
+ // agentMeta 未通过 IF1 时配置不丢
106
+ const engine = agentMeta?.engine ?? extractYamlField(yamlBlock, "engine");
107
+ // 解析期校验(D9:未注册 id 前置暴露,不留到运行时神秘失败)。为什么在解析期而非
108
+ // 路由期:配置错误的根源在 .md 文件,越早报错定位越准(错误含文件路径 + 注册清单)
109
+ if (engine !== undefined && !hasEngine(engine)) {
110
+ throw new EngineNotFoundError(engine, listEngines(), filePath);
111
+ }
103
112
 
104
113
  return {
105
114
  config: {
@@ -107,6 +116,7 @@ export function parseAgentWithMeta(
107
116
  systemPrompt: body,
108
117
  model: agentMeta?.model ?? modelFallback ?? undefined,
109
118
  thinkingLevel: extractYamlField(yamlBlock, "thinkingLevel") ?? undefined,
119
+ ...(engine !== undefined ? { engine } : {}),
110
120
  tools: agentMeta?.tools && agentMeta.tools.length > 0
111
121
  ? agentMeta.tools
112
122
  : (toolsFallback && toolsFallback.length > 0 ? toolsFallback : undefined),
@@ -20,15 +20,16 @@ import type { SubagentsGlobalConfig } from "./types.ts";
20
20
  * 但 config.json 被 .gitignore 排除且不应随 npm 包分发用户私有配置——导致
21
21
  * npm pack 后读不到文件,catch 兜底用空字段,pi install 后首次执行抛错。
22
22
  * 修复:默认值内联在代码里,不依赖任何包内文件。
23
+ *
24
+ * export 供守护测试断言 package.json startupConfig 声明与此深相等(防漂移)。
23
25
  */
24
- const DEFAULT_CONFIG: SubagentsGlobalConfig = {
26
+ export const DEFAULT_CONFIG: SubagentsGlobalConfig = {
25
27
  version: 1,
26
28
  maxConcurrent: 6,
27
29
  };
28
30
 
29
31
  /** 默认 maxConcurrent(DEFAULT_CONFIG 的镜像,sanitize 用)。 */
30
32
  const DEFAULT_MAX_CONCURRENT = 6;
31
-
32
33
  // ============================================================
33
34
  // 路径
34
35
  // ============================================================
@@ -56,9 +57,15 @@ export function loadGlobalConfig(agentDir: string): SubagentsGlobalConfig {
56
57
  try {
57
58
  const raw = fs.readFileSync(configPath, "utf-8");
58
59
  const parsed = JSON.parse(raw) as Partial<SubagentsGlobalConfig>;
60
+ // P4 引擎路由(D9):全局默认引擎 + strict 模式。非法值静默回缺省('pi' /
61
+ // false)——config.json 是用户手编文件,坏值不炸启动(与 maxConcurrent 同判)
62
+ const defaultEngine = sanitizeDefaultEngine(parsed.defaultEngine);
63
+ const engineRouting = sanitizeEngineRouting(parsed.engineRouting);
59
64
  return {
60
65
  version: parsed.version ?? DEFAULT_CONFIG.version,
61
66
  maxConcurrent: sanitizeMaxConcurrent(parsed.maxConcurrent),
67
+ ...(defaultEngine !== undefined ? { defaultEngine } : {}),
68
+ ...(engineRouting !== undefined ? { engineRouting } : {}),
62
69
  };
63
70
  } catch {
64
71
  return { ...DEFAULT_CONFIG };
@@ -71,3 +78,19 @@ function sanitizeMaxConcurrent(value: unknown): number {
71
78
  ? value
72
79
  : DEFAULT_MAX_CONCURRENT;
73
80
  }
81
+
82
+ /**
83
+ * defaultEngine 校验:非空字符串透传,其余 undefined(缺省引擎由路由层落 'pi')。
84
+ * 为什么不在加载期对注册表校验 hasEngine:加载早于组合根注册(agentDir 解析在
85
+ * session_start),注册表此刻可能为空——校验归路由层(getEngine 抛 EngineNotFoundError)。
86
+ */
87
+ function sanitizeDefaultEngine(value: unknown): string | undefined {
88
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
89
+ }
90
+
91
+ /** engineRouting 校验:仅认 strict 布尔,其余键忽略(向前兼容追加)。 */
92
+ function sanitizeEngineRouting(value: unknown): { strict: boolean } | undefined {
93
+ if (typeof value !== "object" || value === null) return undefined;
94
+ const strict = (value as Record<string, unknown>).strict;
95
+ return typeof strict === "boolean" ? { strict } : undefined;
96
+ }
@@ -0,0 +1,53 @@
1
+ // data-dir.test.ts —— dataDir 通道解析(XYZ_AGENT_DATA_DIR 权威通道 + piAgentDir 回退)。
2
+ //
3
+ // 三视角:①构建者——两级解析顺序正确;②使用者——独立 pi 用户(无 xyz env)拿到
4
+ // piAgentDir 作根;③观察者——回退路径 warn 一次(不留静默漂移),不刷屏。
5
+ //
6
+ // 注意:getAgentDir 在 vitest 下 alias 到 mocks/pi-coding-agent.ts(返回
7
+ // /home/user/.pi/agent)——本测试只断言两级解析顺序与 warn 行为,不测 pi SDK 内部。
8
+
9
+ import { beforeEach, describe, expect, it } from "vitest";
10
+
11
+ import { getEngineDataDir, resetDataDirWarnForTests, XYZ_DATA_DIR_ENV } from "../../common/data-dir.ts";
12
+
13
+ beforeEach(() => {
14
+ resetDataDirWarnForTests();
15
+ });
16
+
17
+ describe("getEngineDataDir", () => {
18
+ it("XYZ_AGENT_DATA_DIR 存在 → 权威通道直取,不 warn", () => {
19
+ const warnings: string[] = [];
20
+ const dir = getEngineDataDir({ [XYZ_DATA_DIR_ENV]: "/data/xyz-agent" }, (m) => warnings.push(m));
21
+ expect(dir).toBe("/data/xyz-agent");
22
+ expect(warnings).toEqual([]);
23
+ });
24
+
25
+ it("空串/空白串视为缺失(truthy 语义与 shared getDataDir 的 || 归一对齐)", () => {
26
+ const warnings: string[] = [];
27
+ const dir = getEngineDataDir({ [XYZ_DATA_DIR_ENV]: " " }, (m) => warnings.push(m));
28
+ expect(dir).not.toBe(" ");
29
+ expect(warnings.length).toBe(1);
30
+ });
31
+
32
+ it("缺 env → 回退 piAgentDir(getAgentDir(),mock 值)并 warn 一次", () => {
33
+ const warnings: string[] = [];
34
+ const warn = (m: string): void => warnings.push(m);
35
+ const dir1 = getEngineDataDir({}, warn);
36
+ expect(dir1).toBe("/home/user/.pi/agent"); // vitest mock 的 getAgentDir
37
+ expect(warnings.length).toBe(1);
38
+ expect(warnings[0]).toContain("XYZ_AGENT_DATA_DIR");
39
+
40
+ // warn once:第二次调用不再刷
41
+ const dir2 = getEngineDataDir({}, warn);
42
+ expect(dir2).toBe(dir1);
43
+ expect(warnings.length).toBe(1);
44
+ });
45
+
46
+ it("reset 后再次回退会重新 warn(测试隔离钩子可用)", () => {
47
+ const warnings: string[] = [];
48
+ getEngineDataDir({}, (m) => warnings.push(m));
49
+ resetDataDirWarnForTests();
50
+ getEngineDataDir({}, (m) => warnings.push(m));
51
+ expect(warnings.length).toBe(2);
52
+ });
53
+ });
@@ -0,0 +1,132 @@
1
+ // errors.test.ts —— 引擎层错误 SSOT 的结构锁定。
2
+ //
3
+ // 三视角:①构建者——11 条 code 与设计 §3.3.3 全表一致;②使用者——错误消息
4
+ // code 前缀格式可被字符串匹配分流;③观察者——每条错误必有非空恢复指引(可操作)。
5
+
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import {
9
+ DEFAULT_RECOVERY_HINTS,
10
+ ENGINE_ERROR_CODES,
11
+ EngineError,
12
+ engineRunFailedDetail,
13
+ engineTimeoutDetail,
14
+ isEngineErrorCode,
15
+ nestedSpawnRejectedError,
16
+ promptTooLargeError,
17
+ schemaEmulationFailedDetail,
18
+ STDOUT_TAIL_ECHO_CHARS,
19
+ } from "../../common/errors.ts";
20
+
21
+ describe("ENGINE_ERROR_CODES(§3.3.3 全表)", () => {
22
+ it("11 条错误码与设计文档错误规格表逐条一致", () => {
23
+ expect([...ENGINE_ERROR_CODES]).toEqual([
24
+ "engine_not_found",
25
+ "engine_probe_failed",
26
+ "engine_credential_missing",
27
+ "nested_spawn_rejected",
28
+ "schema_emulation_failed",
29
+ "engine_timeout",
30
+ "engine_capability_unsupported",
31
+ "engine_session_not_resumable",
32
+ "model_not_available",
33
+ "prompt_too_large",
34
+ "engine_run_failed",
35
+ ]);
36
+ });
37
+
38
+ it("isEngineErrorCode 收窄:表内 code 为 true,表外/非字符串为 false", () => {
39
+ expect(isEngineErrorCode("engine_timeout")).toBe(true);
40
+ expect(isEngineErrorCode("engine_not_found")).toBe(true);
41
+ expect(isEngineErrorCode("engine_oops")).toBe(false);
42
+ expect(isEngineErrorCode(42)).toBe(false);
43
+ expect(isEngineErrorCode(undefined)).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("DEFAULT_RECOVERY_HINTS(恢复指引全集)", () => {
48
+ it("11 条 code 每条都有非空恢复指引(可操作,非安慰性文案)", () => {
49
+ for (const code of ENGINE_ERROR_CODES) {
50
+ const hint = DEFAULT_RECOVERY_HINTS[code];
51
+ expect(hint, `recovery hint for ${code}`).toMatch(/\S/);
52
+ expect(hint.length).toBeGreaterThan(20);
53
+ }
54
+ });
55
+
56
+ it("关键恢复动作在指引中(抽查三条错误规格的载体系数)", () => {
57
+ expect(DEFAULT_RECOVERY_HINTS.engine_timeout).toMatch(/stdout tail/i);
58
+ expect(DEFAULT_RECOVERY_HINTS.prompt_too_large).toMatch(/stdin/);
59
+ expect(DEFAULT_RECOVERY_HINTS.nested_spawn_rejected).toMatch(/inside the current task/);
60
+ });
61
+ });
62
+
63
+ describe("EngineError", () => {
64
+ it("message 为 <code>: <detail> 前缀格式(outcome.error 分流依据)", () => {
65
+ const err = new EngineError("engine_timeout", "chain exhausted", "retry with pi");
66
+ expect(err.message).toBe("engine_timeout: chain exhausted");
67
+ expect(err.code).toBe("engine_timeout");
68
+ expect(err.recovery).toBe("retry with pi");
69
+ expect(err.name).toBe("EngineError");
70
+ });
71
+
72
+ it("toStructured 投影 code/message/recovery 三字段", () => {
73
+ const structured = new EngineError("model_not_available", "no such model", "pick another").toStructured();
74
+ expect(structured).toEqual({
75
+ code: "model_not_available",
76
+ message: "model_not_available: no such model",
77
+ recovery: "pick another",
78
+ });
79
+ });
80
+ });
81
+
82
+ describe("具名构造器", () => {
83
+ it("promptTooLargeError:detail 含实际/上限字节数,recovery 含三条恢复建议", () => {
84
+ const err = promptTooLargeError(200_000, 131_072);
85
+ expect(err.code).toBe("prompt_too_large");
86
+ expect(err.message).toContain("200000");
87
+ expect(err.message).toContain("131072");
88
+ expect(err.recovery).toMatch(/shorten the task/i);
89
+ expect(err.recovery).toMatch(/file channel/);
90
+ expect(err.recovery).toMatch(/stdin/);
91
+ });
92
+
93
+ it("nestedSpawnRejectedError:说明防护规则 + 指向 task 内自行完成", () => {
94
+ const err = nestedSpawnRejectedError();
95
+ expect(err.code).toBe("nested_spawn_rejected");
96
+ expect(err.message).toContain("XYZ_AGENT_SUBAGENT");
97
+ expect(err.recovery).toMatch(/inside the current task/);
98
+ });
99
+
100
+ it("engineTimeoutDetail:含 stdout 尾部 + engine: pi 重跑建议", () => {
101
+ const detail = engineTimeoutDetail("partial stdout output");
102
+ expect(detail).toContain("partial stdout output");
103
+ expect(detail).toMatch(/SIGTERM/);
104
+ expect(detail).toMatch(/SIGKILL/);
105
+ expect(detail).toMatch(/`engine: pi`/);
106
+ });
107
+
108
+ it(`engineTimeoutDetail:stdout 尾部截断到 ${STDOUT_TAIL_ECHO_CHARS} 字符`, () => {
109
+ const longTail = "x".repeat(STDOUT_TAIL_ECHO_CHARS + 500);
110
+ const detail = engineTimeoutDetail(longTail);
111
+ // 截断后含省略号标记,总长有界(不含前后固定文案不超过 2000 + 常量开销)
112
+ expect(detail).toContain("...");
113
+ expect(detail.length).toBeLessThan(STDOUT_TAIL_ECHO_CHARS + 600);
114
+ });
115
+
116
+ it("engineRunFailedDetail:含 reason / exit code / 尾部 / 恢复指引", () => {
117
+ const detail = engineRunFailedDetail("stdout parse failed", 3, "some output");
118
+ expect(detail).toContain("stdout parse failed");
119
+ expect(detail).toContain("exit code 3");
120
+ expect(detail).toContain("some output");
121
+ expect(detail).toMatch(/probe/);
122
+ // exitCode=null(被信号杀死)的形态
123
+ expect(engineRunFailedDetail("crash", null, "t")).toContain("killed by signal");
124
+ });
125
+
126
+ it("schemaEmulationFailedDetail:含错误明细 + 原始输出尾部 + 重试一次语义", () => {
127
+ const detail = schemaEmulationFailedDetail("Schema validation failed: /a must be number", '{"a":"x"}');
128
+ expect(detail).toContain("Schema validation failed");
129
+ expect(detail).toContain('{"a":"x"}');
130
+ expect(detail).toMatch(/retry once/i);
131
+ });
132
+ });
@@ -0,0 +1,177 @@
1
+ // event-journal.test.ts —— JournalWriter 写入-重放往返一致 + 中立格式字段断言。
2
+ //
3
+ // 三视角:①构建者——行格式 v/ts/taskId/engineId/seq/event 逐字段、seq 单调;②使用者
4
+ // ——replayJournal 重放即得事件流(read 第②级),文件不存在/坏行不 throw;③观察者
5
+ // ——写失败 warn 留痕且 close 不抛(journal 是尽力而为的②级数据源)。
6
+
7
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+
11
+ import { afterEach, describe, expect, it } from "vitest";
12
+
13
+ import { JournalWriter, replayJournal } from "../../common/event-journal.ts";
14
+ import type { JournalFsDeps } from "../../common/event-journal.ts";
15
+ import type { AgentEvent } from "../../../types.ts";
16
+
17
+ let tmpRoot: string | undefined;
18
+
19
+ function tmpPath(name: string): string {
20
+ tmpRoot ??= mkdtempSync(join(tmpdir(), "engine-journal-test-"));
21
+ const path = join(tmpRoot, name);
22
+ mkdirSync(dirname(path), { recursive: true });
23
+ return path;
24
+ }
25
+
26
+ afterEach(() => {
27
+ if (tmpRoot !== undefined) {
28
+ rmSync(tmpRoot, { recursive: true, force: true });
29
+ tmpRoot = undefined;
30
+ }
31
+ });
32
+
33
+ const EVENTS: AgentEvent[] = [
34
+ { type: "text_delta", delta: "hello " },
35
+ { type: "text_delta", delta: "world" },
36
+ { type: "tool_start", toolName: "bash", args: { cmd: "ls" } },
37
+ { type: "tool_end", toolName: "bash", isError: false },
38
+ { type: "turn_end" },
39
+ ];
40
+
41
+ describe("JournalWriter 写入 + replayJournal 重放", () => {
42
+ it("写入-重放往返一致:append N 事件 → close → replay 深等事件流", async () => {
43
+ const path = tmpPath("roundtrip/journal-bg-1.jsonl");
44
+ const writer = new JournalWriter({ path, taskId: "bg-1", engineId: "zcode" });
45
+ for (const ev of EVENTS) writer.append(ev);
46
+ await writer.close();
47
+
48
+ expect(replayJournal(path)).toEqual(EVENTS);
49
+ });
50
+
51
+ it("中立格式字段断言:每行 {v:1, ts, taskId, engineId, seq, event},seq 单调递增", async () => {
52
+ const path = tmpPath("format/journal-bg-2.jsonl");
53
+ const writer = new JournalWriter({ path, taskId: "bg-2", engineId: "pi" });
54
+ const before = Date.now();
55
+ for (const ev of EVENTS) writer.append(ev);
56
+ await writer.close();
57
+
58
+ const lines = readFileSync(path, "utf8").trim().split("\n");
59
+ expect(lines.length).toBe(EVENTS.length);
60
+ const parsed = lines.map((l) => JSON.parse(l) as Record<string, unknown>);
61
+ let prevSeq = -1;
62
+ for (const [i, row] of parsed.entries()) {
63
+ expect(row.v).toBe(1);
64
+ expect(typeof row.ts).toBe("number");
65
+ expect(row.ts).toBeGreaterThanOrEqual(before);
66
+ expect(row.taskId).toBe("bg-2");
67
+ expect(row.engineId).toBe("pi");
68
+ expect(row.seq).toBe(i);
69
+ expect((row.seq as number) > prevSeq).toBe(true);
70
+ prevSeq = row.seq as number;
71
+ expect(row.event).toEqual(EVENTS[i]);
72
+ }
73
+ });
74
+
75
+ it("close 幂等(二次 close 不追加不抛错);close 后 append 丢弃", async () => {
76
+ const path = tmpPath("idempotent/journal-bg-3.jsonl");
77
+ const writer = new JournalWriter({ path, taskId: "bg-3", engineId: "pi" });
78
+ writer.append({ type: "turn_end" });
79
+ await writer.close();
80
+ writer.append({ type: "error", message: "late" });
81
+ await writer.close();
82
+ expect(replayJournal(path)).toEqual([{ type: "turn_end" }]);
83
+ });
84
+
85
+ it("无事件任务不产生空 journal 文件(惰性创建)", async () => {
86
+ const path = tmpPath("empty/journal-bg-4.jsonl");
87
+ const writer = new JournalWriter({ path, taskId: "bg-4", engineId: "pi" });
88
+ await writer.close();
89
+ expect(existsSync(path)).toBe(false);
90
+ expect(replayJournal(path)).toEqual([]);
91
+ });
92
+ });
93
+
94
+ describe("JournalWriter 写失败(尽力而为语义)", () => {
95
+ function failingFs(): { fs: JournalFsDeps; warnings: string[] } {
96
+ const warnings: string[] = [];
97
+ const fs: JournalFsDeps = {
98
+ mkdir: async () => undefined,
99
+ appendFile: async () => {
100
+ throw new Error("disk full");
101
+ },
102
+ open: async () => {
103
+ throw new Error("unreachable in failed path");
104
+ },
105
+ };
106
+ return { fs, warnings };
107
+ }
108
+
109
+ it("appendFile 失败 → warn 留痕 + failed,close 不抛,后续 append 丢弃", async () => {
110
+ const path = tmpPath("failing/journal-bg-5.jsonl");
111
+ const { fs } = failingFs();
112
+ const warnings: string[] = [];
113
+ const writer = new JournalWriter(
114
+ { path, taskId: "bg-5", engineId: "zcode" },
115
+ fs,
116
+ (msg) => warnings.push(msg),
117
+ );
118
+ writer.append({ type: "turn_end" });
119
+ await writer.flush();
120
+ expect(writer.isFailed).toBe(true);
121
+ expect(warnings.length).toBe(1);
122
+ expect(warnings[0]).toContain("bg-5");
123
+ expect(warnings[0]).toContain("disk full");
124
+
125
+ writer.append({ type: "error", message: "dropped" });
126
+ await writer.close(); // 不抛
127
+ expect(existsSync(path)).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe("replayJournal 容错", () => {
132
+ it("文件不存在 → [](②级不可达不算错误,降级链语义)", () => {
133
+ expect(replayJournal(tmpPath("missing/journal-none.jsonl"))).toEqual([]);
134
+ });
135
+
136
+ it("损坏行跳过(追加写末行可能截断),好行保留", () => {
137
+ const path = tmpPath("corrupt/journal-bg-6.jsonl");
138
+ const good = JSON.stringify({
139
+ v: 1, ts: 1, taskId: "bg-6", engineId: "pi", seq: 0, event: { type: "turn_end" },
140
+ });
141
+ writeFileSync(path, `${good}\n{"v":1,"truncated...\n`, "utf8");
142
+ expect(replayJournal(path)).toEqual([{ type: "turn_end" }]);
143
+ });
144
+
145
+ it("行序乱 → 按 seq 稳定排序返回(重放顺序权威是 seq)", () => {
146
+ const path = tmpPath("unordered/journal-bg-7.jsonl");
147
+ const line = (seq: number, delta: string): string =>
148
+ JSON.stringify({ v: 1, ts: seq, taskId: "bg-7", engineId: "pi", seq, event: { type: "text_delta", delta } });
149
+ writeFileSync(path, `${line(2, "c")}\n${line(0, "a")}\n${line(1, "b")}\n`, "utf8");
150
+ expect(replayJournal(path)).toEqual([
151
+ { type: "text_delta", delta: "a" },
152
+ { type: "text_delta", delta: "b" },
153
+ { type: "text_delta", delta: "c" },
154
+ ]);
155
+ });
156
+
157
+ it("空文件 → []", () => {
158
+ const path = tmpPath("blank/journal-bg-8.jsonl");
159
+ writeFileSync(path, "\n\n", "utf8");
160
+ expect(replayJournal(path)).toEqual([]);
161
+ });
162
+
163
+ it("结构不符的行(缺 event / v≠1)跳过", () => {
164
+ const path = tmpPath("shape/journal-bg-9.jsonl");
165
+ writeFileSync(
166
+ path,
167
+ [
168
+ JSON.stringify({ v: 1, ts: 1, taskId: "t", engineId: "pi", seq: 0, event: { type: "compaction" } }),
169
+ JSON.stringify({ v: 1, ts: 2, taskId: "t", engineId: "pi", seq: 1 }), // 无 event
170
+ JSON.stringify({ v: 2, ts: 3, taskId: "t", engineId: "pi", seq: 2, event: { type: "turn_end" } }), // v≠1
171
+ JSON.stringify({ v: 1, ts: 4, taskId: "t", engineId: "pi", seq: 3, event: "not-an-object" }),
172
+ ].join("\n") + "\n",
173
+ "utf8",
174
+ );
175
+ expect(replayJournal(path)).toEqual([{ type: "compaction" }]);
176
+ });
177
+ });