@zhushanwen/pi-subagent-workflow 0.1.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 (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,82 @@
1
+ // src/__tests__/finalized-marker.test.ts
2
+ //
3
+ // finalized-marker 专属测试。
4
+ // 覆盖:write→read 往返 / 缺 sidecar → false / best-effort IO 错静默 / .cancelled 互斥。
5
+
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+
10
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
11
+
12
+ import { readFinalized, writeFinalized } from "../finalized-marker.ts";
13
+
14
+ describe("finalized-marker", () => {
15
+ let tmpDir: string;
16
+ let sessionFile: string;
17
+
18
+ beforeEach(() => {
19
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-test-"));
20
+ sessionFile = path.join(tmpDir, "2026-01-01_uuid.jsonl");
21
+ });
22
+ afterEach(() => {
23
+ fs.rmSync(tmpDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("write → read 往返", () => {
27
+ it("写入后 readFinalized 返回 true", () => {
28
+ writeFinalized(sessionFile);
29
+ expect(readFinalized(sessionFile)).toBe(true);
30
+ });
31
+
32
+ it("sidecar 路径 = sessionFile + '.finalized'", () => {
33
+ writeFinalized(sessionFile);
34
+ expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
35
+ });
36
+
37
+ it("sidecar 内容为空(存在性即信号)", () => {
38
+ writeFinalized(sessionFile);
39
+ const content = fs.readFileSync(`${sessionFile}.finalized`, "utf-8");
40
+ expect(content).toBe("");
41
+ });
42
+ });
43
+
44
+ describe("readFinalized 无 sidecar", () => {
45
+ it("不存在 → false", () => {
46
+ expect(readFinalized(sessionFile)).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe("best-effort IO 错静默", () => {
51
+ it("writeFileSync 抛错时不抛出", () => {
52
+ // 目标路径不存在(不存在的父目录),writeFileSync 会抛
53
+ const badPath = path.join(tmpDir, "nonexistent-sub", "session.jsonl");
54
+ expect(() => writeFinalized(badPath)).not.toThrow();
55
+ });
56
+
57
+ it("写失败后 readFinalized 返回 false(sidecar 未写入)", () => {
58
+ const badPath = path.join(tmpDir, "nonexistent-sub", "session.jsonl");
59
+ writeFinalized(badPath); // 静默失败
60
+ expect(readFinalized(badPath)).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("与 .cancelled 互斥(BC-4)", () => {
65
+ it("写 finalized 时删除已有的 .cancelled", () => {
66
+ // 先写一个 .cancelled sidecar
67
+ fs.writeFileSync(`${sessionFile}.cancelled`, `{"id":"bg-1","status":"cancelled","agent":"w","startedAt":1,"endedAt":2}\n`, "utf-8");
68
+ expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(true);
69
+
70
+ // 写 finalized → .cancelled 应被删除
71
+ writeFinalized(sessionFile);
72
+ expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(false);
73
+ expect(readFinalized(sessionFile)).toBe(true);
74
+ });
75
+
76
+ it("无 .cancelled 时写 finalized 正常(不报错)", () => {
77
+ expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(false);
78
+ expect(() => writeFinalized(sessionFile)).not.toThrow();
79
+ expect(readFinalized(sessionFile)).toBe(true);
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,135 @@
1
+ // src/__tests__/format-schema-instruction.test.ts
2
+ //
3
+ // 锁定 formatSchemaInstruction 契约:构造 schema enforcement 的 MANDATORY 指令。
4
+ // 该字符串被拼入 task 末尾(runSpawn 的 fullTask = task + instruction),且 steer
5
+ // reminder 复用同一文本。一旦漏掉 "MUST call structured-output" 关键词或 JSON
6
+ // 序列化漂移,schema 模式会静默失效——agent 可能直接把 JSON 写进文本响应。
7
+ //
8
+ // 纯函数测试:不依赖 Pi 运行时、不 spawn 进程、不 mock。只 import 被测函数。
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { formatSchemaInstruction } from "../session-runner.ts";
12
+
13
+ describe("formatSchemaInstruction", () => {
14
+ // ── 指令文本契约 ──────────────────────────────────────────────
15
+
16
+ it("contains the structured-output tool keyword", () => {
17
+ const out = formatSchemaInstruction({ type: "object" });
18
+ expect(out).toContain("structured-output");
19
+ });
20
+
21
+ it("emits a MANDATORY structured-output directive (not free-form JSON)", () => {
22
+ const out = formatSchemaInstruction({ type: "object" });
23
+ expect(out).toContain("MANDATORY");
24
+ expect(out).toContain("MUST");
25
+ expect(out).toContain("MUST call the `structured-output` tool");
26
+ expect(out).toContain("Do NOT output the JSON directly");
27
+ });
28
+
29
+ // ── schema 序列化 ─────────────────────────────────────────────
30
+
31
+ it("embeds the schema as pretty-printed JSON (indent=2) inside a fenced block", () => {
32
+ const schema: Record<string, unknown> = {
33
+ type: "object",
34
+ properties: { name: { type: "string" } },
35
+ };
36
+ const out = formatSchemaInstruction(schema);
37
+ // 必须包含 JSON.stringify(schema, null, 2) 的完整结果
38
+ expect(out).toContain(JSON.stringify(schema, null, 2));
39
+ expect(out).toContain("```json");
40
+ expect(out).toContain("```");
41
+ // indent=2 的可观察证据:属性键前有恰好两个空格(object 第一层缩进)
42
+ expect(out).toContain('\n "type": "object"');
43
+ });
44
+
45
+ it("locks the full output structure for a minimal schema", () => {
46
+ const out = formatSchemaInstruction({ type: "object" });
47
+ // 完整结构快照——任何指令措辞/顺序/缩进漂移都会被捕获。
48
+ // 注意第三行末尾的 em-dash(—),防止有人把它替换成普通连字符。
49
+ expect(out).toBe(
50
+ [
51
+ "MANDATORY: Structured Output Requirement",
52
+ "You MUST call the `structured-output` tool with your final answer.",
53
+ "Do NOT output the JSON directly in your text response — you MUST use the structured-output tool.",
54
+ "The schema for the structured output is:",
55
+ "```json",
56
+ '{',
57
+ ' "type": "object"',
58
+ '}',
59
+ "```",
60
+ ].join("\n"),
61
+ );
62
+ });
63
+
64
+ // ── 特殊字符转义(注入风险路径)──────────────────────────────
65
+
66
+ it("escapes double quotes inside schema string values", () => {
67
+ const schema: Record<string, unknown> = { prompt: 'say "hi"' };
68
+ const out = formatSchemaInstruction(schema);
69
+ // JSON.stringify 会把内层 " 转义为 \"
70
+ expect(out).toContain('say \\"hi\\"');
71
+ // 原始未转义形式(含成对字面双引号)绝不能回流进 JSON 体内
72
+ expect(out).not.toContain('say "hi"');
73
+ });
74
+
75
+ it("escapes newlines inside schema string values", () => {
76
+ const schema: Record<string, unknown> = { text: "line1\nline2" };
77
+ const out = formatSchemaInstruction(schema);
78
+ // 换行被序列化为字面反斜杠-n,不能是真实换行符
79
+ expect(out).toContain("line1\\nline2");
80
+ expect(out).not.toContain("line1\nline2");
81
+ });
82
+
83
+ it("escapes backslashes inside schema string values", () => {
84
+ const schema: Record<string, unknown> = { path: "C:\\Users\\x" };
85
+ const out = formatSchemaInstruction(schema);
86
+ // 单反斜杠被序列化为 \\,避免后续解析误把转义序列当指令
87
+ expect(out).toContain("C:\\\\Users\\\\x");
88
+ expect(out).not.toContain("C:\\Users\\x");
89
+ });
90
+
91
+ // ── 边界值 ────────────────────────────────────────────────────
92
+
93
+ it("handles empty schema object", () => {
94
+ const out = formatSchemaInstruction({});
95
+ expect(out).toContain("structured-output");
96
+ expect(out).toContain("{}");
97
+ });
98
+
99
+ it("preserves null values in schema", () => {
100
+ const schema: Record<string, unknown> = { default: null };
101
+ const out = formatSchemaInstruction(schema);
102
+ // JSON.stringify 对 null 保留字面 "null"(不会 omit 键,也不会变字符串)
103
+ expect(out).toContain('"default": null');
104
+ });
105
+
106
+ it("serializes nested objects and arrays", () => {
107
+ const schema: Record<string, unknown> = {
108
+ type: "object",
109
+ required: ["name", "age"],
110
+ properties: {
111
+ name: { type: "string" },
112
+ age: { type: "integer", minimum: 0 },
113
+ },
114
+ };
115
+ const out = formatSchemaInstruction(schema);
116
+ expect(out).toContain(JSON.stringify(schema, null, 2));
117
+ // 嵌套结构 indent 正确(第二层 4 空格)
118
+ expect(out).toContain(' "name": {');
119
+ expect(out).toContain(' "type": "string"');
120
+ });
121
+
122
+ // ── 确定性 ────────────────────────────────────────────────────
123
+
124
+ it("is deterministic — same schema produces identical output", () => {
125
+ const schema: Record<string, unknown> = { a: 1, b: [2, 3] };
126
+ expect(formatSchemaInstruction(schema)).toBe(formatSchemaInstruction(schema));
127
+ });
128
+
129
+ it("is deterministic across different object key insertion (value-equal schemas)", () => {
130
+ // JSON.stringify 按对象自身属性顺序序列化;同序构造的等价 schema 应产出相同指令
131
+ const a: Record<string, unknown> = { x: 1, y: 2 };
132
+ const b: Record<string, unknown> = { x: 1, y: 2 };
133
+ expect(formatSchemaInstruction(a)).toBe(formatSchemaInstruction(b));
134
+ });
135
+ });
@@ -0,0 +1,320 @@
1
+ // src/__tests__/format.test.ts
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import {
6
+ formatElapsedSeconds,
7
+ formatTokens,
8
+ padToVisible,
9
+ sanitizeLabel,
10
+ segFillColored,
11
+ shortId,
12
+ spinnerGlyph,
13
+ statusGlyph,
14
+ truncLine,
15
+ wrapText,
16
+ } from "../../interface/format.ts";
17
+
18
+ // ============================================================
19
+ // formatTokens
20
+ // ============================================================
21
+ describe("formatTokens", () => {
22
+ it("shows plain value below 1000", () => {
23
+ expect(formatTokens(0)).toBe("0");
24
+ expect(formatTokens(820)).toBe("820");
25
+ expect(formatTokens(999)).toBe("999");
26
+ });
27
+
28
+ it("shows N.Nk between 1000 and 9999", () => {
29
+ expect(formatTokens(1000)).toBe("1.0k");
30
+ expect(formatTokens(8200)).toBe("8.2k");
31
+ expect(formatTokens(9999)).toBe("10.0k");
32
+ });
33
+
34
+ it("shows rounded Nk at 10000+", () => {
35
+ expect(formatTokens(10000)).toBe("10k");
36
+ expect(formatTokens(23000)).toBe("23k");
37
+ expect(formatTokens(99999)).toBe("100k");
38
+ });
39
+ });
40
+
41
+ // ============================================================
42
+ // formatElapsedSeconds
43
+ // ============================================================
44
+ describe("formatElapsedSeconds", () => {
45
+ it("shows Xs below 60", () => {
46
+ expect(formatElapsedSeconds(0)).toBe("0s");
47
+ expect(formatElapsedSeconds(12)).toBe("12s");
48
+ expect(formatElapsedSeconds(59)).toBe("59s");
49
+ });
50
+
51
+ it("shows Xm Ys between 60 and 3599", () => {
52
+ expect(formatElapsedSeconds(60)).toBe("1m0s");
53
+ expect(formatElapsedSeconds(72)).toBe("1m12s");
54
+ expect(formatElapsedSeconds(3599)).toBe("59m59s");
55
+ });
56
+
57
+ it("shows Xh Ym at 3600+", () => {
58
+ expect(formatElapsedSeconds(3600)).toBe("1h0m");
59
+ expect(formatElapsedSeconds(3661)).toBe("1h1m");
60
+ expect(formatElapsedSeconds(7325)).toBe("2h2m");
61
+ });
62
+ });
63
+
64
+ // ============================================================
65
+ // statusGlyph
66
+ // ============================================================
67
+ describe("statusGlyph", () => {
68
+ it("running → no icon, accent color", () => {
69
+ expect(statusGlyph("running")).toEqual({ icon: undefined, color: "accent" });
70
+ });
71
+
72
+ it("done → checkmark, success", () => {
73
+ expect(statusGlyph("done")).toEqual({ icon: "✓", color: "success" });
74
+ });
75
+
76
+ it("failed → cross, error", () => {
77
+ expect(statusGlyph("failed")).toEqual({ icon: "✗", color: "error" });
78
+ });
79
+
80
+ it("cancelled → square, muted", () => {
81
+ expect(statusGlyph("cancelled")).toEqual({ icon: "■", color: "muted" });
82
+ });
83
+ });
84
+
85
+ // ============================================================
86
+ // spinnerGlyph
87
+ // ============================================================
88
+ describe("spinnerGlyph", () => {
89
+ it("returns a frame for valid seed", () => {
90
+ expect(spinnerGlyph(0)).toBe("⠋");
91
+ expect(spinnerGlyph(1)).toBe("⠙");
92
+ expect(spinnerGlyph(9)).toBe("⠏");
93
+ });
94
+
95
+ it("wraps around (mod 10)", () => {
96
+ expect(spinnerGlyph(10)).toBe("⠋");
97
+ expect(spinnerGlyph(15)).toBe("⠴"); // index 5
98
+ });
99
+
100
+ it("falls back to frame 0 on NaN", () => {
101
+ expect(spinnerGlyph(NaN)).toBe("⠋");
102
+ });
103
+
104
+ it("handles negative seeds via abs", () => {
105
+ expect(spinnerGlyph(-1)).toBe("⠙"); // abs(-1) % 10 = 1 → ⠙
106
+ expect(spinnerGlyph(-10)).toBe("⠋");
107
+ });
108
+
109
+ it("falls back to frame 0 on Infinity", () => {
110
+ expect(spinnerGlyph(Infinity)).toBe("⠋");
111
+ });
112
+ });
113
+
114
+ // ============================================================
115
+ // sanitizeLabel
116
+ // ============================================================
117
+ describe("sanitizeLabel", () => {
118
+ it("replaces CRLF/LF with single space", () => {
119
+ expect(sanitizeLabel("line1\r\nline2\nline3")).toBe("line1 line2 line3");
120
+ });
121
+
122
+ it("replaces tabs with 2 spaces", () => {
123
+ expect(sanitizeLabel("a\tb")).toBe("a b");
124
+ });
125
+
126
+ it("collapses multiple consecutive newlines into one space", () => {
127
+ // /[\r\n]+/g treats \r\n\r\n as one match → single space
128
+ expect(sanitizeLabel("a\r\n\r\nb")).toBe("a b");
129
+ });
130
+
131
+ it("leaves clean text unchanged", () => {
132
+ expect(sanitizeLabel("read foo.ts")).toBe("read foo.ts");
133
+ });
134
+ });
135
+
136
+ // ============================================================
137
+ // padToVisible
138
+ // ============================================================
139
+ describe("padToVisible", () => {
140
+ it("pads short text to width with trailing spaces", () => {
141
+ expect(padToVisible("ab", 5)).toBe("ab ");
142
+ });
143
+
144
+ it("returns unchanged when already at width", () => {
145
+ expect(padToVisible("hello", 5)).toBe("hello");
146
+ });
147
+
148
+ it("returns unchanged when wider than width", () => {
149
+ expect(padToVisible("hello world", 5)).toBe("hello world");
150
+ });
151
+
152
+ it("handles CJK width (2 columns per char)", () => {
153
+ // 你好 = 4 visible columns
154
+ expect(padToVisible("你好", 6)).toBe("你好 ");
155
+ });
156
+ });
157
+
158
+ // ============================================================
159
+ // segFillColored
160
+ // ============================================================
161
+ describe("segFillColored", () => {
162
+ it("returns empty string for width <= 0", () => {
163
+ expect(segFillColored("title", "-", 0)).toBe("");
164
+ expect(segFillColored("title", "-", -1)).toBe("");
165
+ });
166
+
167
+ it("pure fill when no title", () => {
168
+ expect(segFillColored(undefined, "-", 5)).toBe("-----");
169
+ });
170
+
171
+ it("title + fill to width", () => {
172
+ expect(segFillColored("Hi", "-", 5)).toBe("Hi---");
173
+ });
174
+
175
+ it("truncates title when wider than width", () => {
176
+ const result = segFillColored("Hello World", "-", 5);
177
+ // title visible width 11 > 5 → truncated to 5 (with ellipsis = 4 chars + …)
178
+ expect(result.length).toBeLessThanOrEqual(10); // visible width 5 but may include ANSI
179
+ });
180
+
181
+ it("preserves ANSI in title and fill separately (no nesting color loss)", () => {
182
+ const redTitle = "\x1b[31mHi\x1b[0m";
183
+ const blueFill = "\x1b[34m-\x1b[0m";
184
+ const result = segFillColored(redTitle, blueFill, 5);
185
+ // title visible width = 2 ("Hi"), fill count = 3
186
+ expect(result).toContain(redTitle);
187
+ expect(result).toContain(blueFill);
188
+ // fill repeated 3 times
189
+ expect(result).toBe(redTitle + blueFill + blueFill + blueFill);
190
+ });
191
+ });
192
+
193
+ // ============================================================
194
+ // truncLine
195
+ // ============================================================
196
+ describe("truncLine", () => {
197
+ it("returns text unchanged when within width", () => {
198
+ expect(truncLine("hello", 10)).toBe("hello");
199
+ expect(truncLine("hello", 5)).toBe("hello");
200
+ });
201
+
202
+ it("returns empty string for width <= 0", () => {
203
+ expect(truncLine("hello", 0)).toBe("");
204
+ });
205
+
206
+ it("truncates with ellipsis when exceeding width", () => {
207
+ const result = truncLine("hello world", 8);
208
+ // 纯文本截断不发 \x1b[0m(全局重置会破坏外层背景色)
209
+ expect(result.endsWith("…")).toBe(true);
210
+ expect(result).not.toContain("\x1b[0m");
211
+ // visible width should be 8 (7 chars + ellipsis)
212
+ });
213
+
214
+ it("handles CJK characters (2 columns each)", () => {
215
+ // 你好世界 = 8 visible columns; truncate to 5 → 2 chars (4 cols) + …
216
+ const result = truncLine("你好世界", 5);
217
+ expect(result.endsWith("…")).toBe(true);
218
+ expect(result).not.toContain("\x1b[0m");
219
+ });
220
+
221
+ it("handles emoji correctly", () => {
222
+ const result = truncLine("😀😁😂🤣😃", 3);
223
+ expect(result.endsWith("…")).toBe(true);
224
+ expect(result).not.toContain("\x1b[0m");
225
+ });
226
+
227
+ it("reapplies active ANSI styles before ellipsis (no background break)", () => {
228
+ // red text that exceeds width → ellipsis should have red re-applied
229
+ const input = "\x1b[31mhello world this is long\x1b[0m";
230
+ const result = truncLine(input, 10);
231
+ expect(result.endsWith("…\x1b[0m")).toBe(true);
232
+ // The ellipsis should be preceded by the active red style (re-applied)
233
+ // Check that the last grapheme sequence includes the red SGR before …
234
+ expect(result).toMatch(/\x1b\[31m…\x1b\[0m$/);
235
+ });
236
+
237
+ it("clears style stack on reset code", () => {
238
+ // text with reset in the middle → after reset, no style re-applied
239
+ const input = "\x1b[31mab\x1b[0mcdefghijk";
240
+ const result = truncLine(input, 6);
241
+ // reset 后 activeStyles 为空 → 截断不发 \x1b[0m
242
+ expect(result.endsWith("…")).toBe(true);
243
+ });
244
+
245
+ it("flattens newlines to spaces (single-line rendering safety)", () => {
246
+ // 多行 prompt / turn.text 含 \n,单行渲染时 \n 会意外换行破坏行对齐。
247
+ // truncLine 作为单行渲染入口必须剥离 \n(用空格替代保留词边界)。
248
+ const multiLine = "只读任务:分析核心逻辑。\n项目根:/Users/test\n输出:报告";
249
+ const result = truncLine(multiLine, 80);
250
+ expect(result).not.toContain("\n");
251
+ expect(result).toContain("只读任务");
252
+ expect(result).toContain("项目根");
253
+ });
254
+
255
+ it("flattens \r\n sequences", () => {
256
+ const crlf = "line1\r\nline2\r\nline3";
257
+ const result = truncLine(crlf, 80);
258
+ expect(result).not.toContain("\r");
259
+ expect(result).not.toContain("\n");
260
+ });
261
+ });
262
+
263
+ // ============================================================
264
+ // wrapText
265
+ // ============================================================
266
+ describe("wrapText", () => {
267
+ it("returns text as-is when shorter than width", () => {
268
+ expect(wrapText("hello", 80)).toEqual(["hello"]);
269
+ });
270
+
271
+ it("wraps long text into multiple lines (no truncation)", () => {
272
+ const text = "abcdefghij"; // 10 chars
273
+ const lines = wrapText(text, 4);
274
+ // 每行最多 4 列,10 字符 → 3 行(4+4+2),不截断不省略号
275
+ expect(lines.join("")).toBe("abcdefghij");
276
+ for (const l of lines.slice(0, -1)) {
277
+ expect(visibleWidth(l)).toBeLessThanOrEqual(4);
278
+ }
279
+ });
280
+
281
+ it("preserves original newlines as paragraph breaks", () => {
282
+ const text = "第一行\n第二行";
283
+ const lines = wrapText(text, 80);
284
+ expect(lines).toEqual(["第一行", "第二行"]);
285
+ });
286
+
287
+ it("wraps CJK text correctly (2 columns each)", () => {
288
+ // 你好世界你好世界 = 8 CJK chars = 16 columns, wrap to 4 columns
289
+ const text = "你好世界你好世界";
290
+ const lines = wrapText(text, 4);
291
+ // 每行最多 4 列 = 2 CJK chars, 共 4 行
292
+ expect(lines).toHaveLength(4);
293
+ expect(lines.join("")).toBe(text);
294
+ });
295
+
296
+ it("handles width <= 0 by returning original", () => {
297
+ expect(wrapText("hello", 0)).toEqual(["hello"]);
298
+ });
299
+
300
+ it("preserves empty lines from blank paragraphs", () => {
301
+ const text = "a\n\nb";
302
+ expect(wrapText(text, 80)).toEqual(["a", "", "b"]);
303
+ });
304
+ });
305
+
306
+ // ============================================================
307
+ // shortId
308
+ // ============================================================
309
+ describe("shortId", () => {
310
+ it("returns sync id unchanged (run-N already short)", () => {
311
+ expect(shortId("run-1")).toBe("run-1");
312
+ expect(shortId("run-42")).toBe("run-42");
313
+ });
314
+
315
+ it("strips timestamp from background id (bg-tag-seq-<ts> → bg-tag-seq)", () => {
316
+ // 真实格式:bg-${6位hex tag}-${seq}-${Date.now()}(subagent-service.ts:422)
317
+ expect(shortId("bg-f6f731-10-1719500000000")).toBe("bg-f6f731-10");
318
+ expect(shortId("bg-abc123-99-1719500123456")).toBe("bg-abc123-99");
319
+ });
320
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 创建一个结构兼容的 ExtensionAPI mock,供 sdk-contract / 注册契约测试用。
3
+ *
4
+ * ExtensionAPI 有 ~30 个必需方法(on/registerTool/registerCommand/...),逐个手写
5
+ * 不现实。本 helper 用 Proxy 把所有未显式 override 的方法/属性短路为 no-op 函数,
6
+ * 让对象**结构兼容** ExtensionAPI——避免 `as unknown as ExtensionAPI` 双重断言
7
+ * (taste/no-unsafe-cast 规则禁止)。测试只需 override 关心的方法:
8
+ *
9
+ * const pi = mockExtensionApi({
10
+ * registerCommand: (name) => { capturedName = name; },
11
+ * });
12
+ *
13
+ * Proxy 的 trap 对所有属性访问返回 override 值或 no-op 函数,运行时安全
14
+ * (注册 handler 只调用被 override 的方法,其余方法测试不触及)。
15
+ */
16
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
17
+
18
+ export function mockExtensionApi(
19
+ overrides: Record<string, unknown> = {},
20
+ ): ExtensionAPI {
21
+ const noop = (): void => { /* test mock: method not invoked by this test */ };
22
+ // Proxy<T> 泛型参数决定返回类型——直接声明为 ExtensionAPI,
23
+ // TS 接受(Proxy handler 对 target 的类型不约束 T)。
24
+ return new Proxy<ExtensionAPI>(overrides as ExtensionAPI, {
25
+ get(target, prop: string | symbol): unknown {
26
+ if (prop in target) return target[prop as keyof ExtensionAPI];
27
+ return noop;
28
+ },
29
+ });
30
+ }