@zhushanwen/pi-subagent-workflow 8.5.0 → 8.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +6 -2
- package/src/execution/__tests__/delivery-methods.test.ts +38 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
- package/src/execution/__tests__/execution-record.test.ts +110 -0
- package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
- package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
- package/src/execution/__tests__/index-session-start.test.ts +86 -7
- package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
- package/src/execution/__tests__/list-fields.test.ts +45 -14
- package/src/execution/__tests__/model-resolver.test.ts +57 -5
- package/src/execution/__tests__/notifier-flush.test.ts +64 -26
- package/src/execution/__tests__/notify-ledger.test.ts +826 -0
- package/src/execution/__tests__/output-collector.test.ts +299 -2
- package/src/execution/__tests__/rpc-mode.test.ts +1 -1
- package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
- package/src/execution/__tests__/spawn-args.test.ts +37 -26
- package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +94 -1
- package/src/execution/__tests__/timeout-integration.test.ts +220 -2
- package/src/execution/__tests__/tool-action.test.ts +92 -1
- package/src/execution/agent-registry.ts +6 -0
- package/src/execution/argv-mirror.ts +5 -1
- package/src/execution/concurrency-pool.ts +1 -1
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +13 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +11 -1
- package/src/execution/engine/types.ts +6 -1
- package/src/execution/execute-options-mapper.ts +8 -7
- package/src/execution/execution-record.ts +60 -1
- package/src/execution/lifecycle-manager.ts +23 -1
- package/src/execution/model-config-service.ts +16 -1
- package/src/execution/model-resolver.ts +31 -59
- package/src/execution/notifier.ts +105 -35
- package/src/execution/notify-ledger.ts +580 -0
- package/src/execution/output-collector.ts +143 -3
- package/src/execution/session-runner.ts +304 -71
- package/src/execution/subagent-service.ts +24 -2
- package/src/execution/subprocess-agent-runner.ts +14 -0
- package/src/execution/types.ts +68 -5
- package/src/execution/ui-request-queue.ts +14 -4
- package/src/index.ts +54 -1
- package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
- package/src/interface/bg-notify-render.ts +33 -12
- package/src/interface/helpers.ts +2 -2
- package/src/interface/subagent-actions.ts +26 -9
- package/src/interface/subagent-tool-schema.ts +156 -0
- package/src/interface/subagent-tool.ts +56 -125
- package/src/interface/subagents.ts +2 -2
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +16 -3
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
- package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
- package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
- package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
- package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
- package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
- package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
- package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +21 -2
- package/src/orchestration/agent-opts-resolver.ts +104 -23
- package/src/orchestration/error-recovery.ts +189 -33
- package/src/orchestration/execute-agent-call.ts +39 -0
- package/src/orchestration/jsonl-run-store.ts +121 -7
- package/src/orchestration/launcher.ts +60 -15
- package/src/orchestration/lifecycle.ts +10 -7
- package/src/orchestration/models/__tests__/budget.test.ts +1 -61
- package/src/orchestration/models/budget.ts +5 -35
- package/src/orchestration/models/run-runtime.ts +24 -9
- package/src/orchestration/models/types.ts +9 -0
- package/src/orchestration/script-lint.ts +1 -1
- package/src/orchestration/skill-discovery.ts +31 -8
- package/src/orchestration/worker-script-builder.ts +16 -3
- package/src/shared/__tests__/model-ref.test.ts +306 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
- package/src/shared/__tests__/timer-delay.test.ts +61 -0
- package/src/shared/model-ref.ts +286 -0
- package/src/shared/schema-env.ts +44 -0
- package/src/shared/schema-jsonify.ts +6 -4
- package/src/shared/timer-delay.ts +54 -0
- package/workflows/review-fix-loop-utils.cjs +9 -7
- package/workflows/review-fix-loop.js +20 -12
- package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
- package/src/orchestration/concurrency-gate.ts +0 -69
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
|
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"ajv": "^8.20.0",
|
|
63
63
|
"yaml": "^2.9.0",
|
|
64
|
-
"@xyz-agent/session-delivery": "0.2.0",
|
|
65
|
-
"@zhushanwen/pi-extension-logger": "0.3.0",
|
|
66
64
|
"@xyz-agent/extension-protocol": "0.7.0",
|
|
65
|
+
"@xyz-agent/session-delivery": "0.3.0",
|
|
66
|
+
"@zhushanwen/pi-extension-logger": "0.3.0",
|
|
67
67
|
"@zhushanwen/pi-file-lock": "0.1.2"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
72
72
|
"@earendil-works/pi-tui": "^0.84.1",
|
|
73
73
|
"typebox": "*",
|
|
74
|
-
"@zhushanwen/pi-pending-notifications": "0.
|
|
75
|
-
"@zhushanwen/pi-structured-output": "5.0
|
|
74
|
+
"@zhushanwen/pi-pending-notifications": "0.5.0",
|
|
75
|
+
"@zhushanwen/pi-structured-output": "5.1.0"
|
|
76
76
|
},
|
|
77
77
|
"peerDependenciesMeta": {
|
|
78
78
|
"@earendil-works/pi-coding-agent": {
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
},
|
|
90
90
|
"scripts": {
|
|
91
91
|
"typecheck": "npx tsc --noEmit",
|
|
92
|
-
"test": "vitest run"
|
|
92
|
+
"test": "vitest run",
|
|
93
|
+
"test:isolated": "env $(env | grep -o '^PI_SUBAGENT_[A-Z_]*' | sed 's/^/-u /' | tr '\n' ' ') vitest run"
|
|
93
94
|
}
|
|
94
95
|
}
|
|
@@ -128,6 +128,79 @@ describe("renderBgNotifyMessage", () => {
|
|
|
128
128
|
expect(comp!.render(80).join("\n")).toContain("cancelled");
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
// ── U3 C-outcome:渲染器切读 outcome ──
|
|
132
|
+
|
|
133
|
+
it("[U3] details 携带 outcome='failed' → verb 为 failed + Error 行(直读一等字段)", () => {
|
|
134
|
+
const { theme } = makeTheme();
|
|
135
|
+
const comp = renderBgNotifyMessage(
|
|
136
|
+
{ details: { status: "closed", outcome: "failed", agent: "w", id: "bg-4", error: "kaboom" } },
|
|
137
|
+
{ expanded: false },
|
|
138
|
+
theme,
|
|
139
|
+
);
|
|
140
|
+
const joined = comp!.render(80).join("\n");
|
|
141
|
+
expect(joined).toContain("failed");
|
|
142
|
+
expect(joined).toContain("Error");
|
|
143
|
+
expect(joined).toContain("kaboom");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("[U3] details 携带 outcome='completed' → verb 为 finished,结果行可见", () => {
|
|
147
|
+
const { theme } = makeTheme();
|
|
148
|
+
const comp = renderBgNotifyMessage(
|
|
149
|
+
{ details: { status: "closed", outcome: "completed", agent: "w", id: "bg-5", result: "ok" } },
|
|
150
|
+
{ expanded: false },
|
|
151
|
+
theme,
|
|
152
|
+
);
|
|
153
|
+
const joined = comp!.render(80).join("\n");
|
|
154
|
+
expect(joined).toContain("finished");
|
|
155
|
+
expect(joined).toContain("ok");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("[U3] outcome 优先于 closedReason(一等字段胜出)", () => {
|
|
159
|
+
const { theme } = makeTheme();
|
|
160
|
+
const comp = renderBgNotifyMessage(
|
|
161
|
+
{
|
|
162
|
+
details: {
|
|
163
|
+
status: "closed", outcome: "cancelled", closedReason: "gc", agent: "w", id: "bg-6",
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{ expanded: false },
|
|
167
|
+
theme,
|
|
168
|
+
);
|
|
169
|
+
const joined = comp!.render(80).join("\n");
|
|
170
|
+
expect(joined).toContain("cancelled");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("[U3] 升级前旧消息重放(details 无 outcome,仅 closedReason+error)→ deriveOutcome 兑底不崩溃", () => {
|
|
174
|
+
const { theme } = makeTheme();
|
|
175
|
+
const comp = renderBgNotifyMessage(
|
|
176
|
+
{
|
|
177
|
+
details: {
|
|
178
|
+
status: "closed", closedReason: "gc", error: "legacy boom", agent: "w", id: "bg-7",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{ expanded: false },
|
|
182
|
+
theme,
|
|
183
|
+
);
|
|
184
|
+
const joined = comp!.render(80).join("\n");
|
|
185
|
+
expect(joined).toContain("failed");
|
|
186
|
+
expect(joined).toContain("legacy boom");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("[U3] 非法 outcome 值按缺失处理(防御性收窄,不崩溃,兑底派生)", () => {
|
|
190
|
+
const { theme } = makeTheme();
|
|
191
|
+
const comp = renderBgNotifyMessage(
|
|
192
|
+
{
|
|
193
|
+
details: {
|
|
194
|
+
status: "closed", outcome: "bogus", closedReason: "gc", result: "fine", agent: "w", id: "bg-8",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{ expanded: false },
|
|
198
|
+
theme,
|
|
199
|
+
);
|
|
200
|
+
const joined = comp!.render(80).join("\n");
|
|
201
|
+
expect(joined).toContain("finished");
|
|
202
|
+
});
|
|
203
|
+
|
|
131
204
|
it("details 缺失/结构不全 → undefined(走 Pi 默认渲染兜底)", () => {
|
|
132
205
|
const { theme } = makeTheme();
|
|
133
206
|
expect(renderBgNotifyMessage({ details: undefined }, { expanded: false }, theme)).toBeUndefined();
|
|
@@ -176,10 +176,14 @@ const CTX_MODEL: ModelInfo = { id: "m", name: "M", provider: "p", reasoning: fal
|
|
|
176
176
|
|
|
177
177
|
/** registry:可解析 "zcode/glm"(taskSpec 字段用例的显式 model),其余未配置。 */
|
|
178
178
|
function fakeRegistry(): ModelRegistryLike {
|
|
179
|
+
// [U1] getAvailable 与 find 必须同源:assertCanonicalModelRef 以 getAvailable 为
|
|
180
|
+
// 全等/孪生扫描面(D1④ 孪生守卫需复扫 registry 全集)。旧 mock 只在 find 特判、
|
|
181
|
+
// getAvailable 返回空,与真实 registry 契约(find 可命中 ⇔ getAvailable 列出)不符。
|
|
182
|
+
const AVAILABLE: ModelInfo[] = [{ id: "glm", name: "GLM", provider: "zcode", reasoning: true }];
|
|
179
183
|
return {
|
|
180
|
-
getAvailable: () =>
|
|
184
|
+
getAvailable: () => AVAILABLE,
|
|
181
185
|
find: (provider: string, id: string) =>
|
|
182
|
-
provider ===
|
|
186
|
+
AVAILABLE.find((m) => m.provider === provider && m.id === id),
|
|
183
187
|
hasConfiguredAuth: () => true,
|
|
184
188
|
};
|
|
185
189
|
}
|
|
@@ -96,7 +96,9 @@ function makeIdleRecord(id = "sa-chat"): ExecutionRecord {
|
|
|
96
96
|
|
|
97
97
|
/** PassThrough child(可读出 stdin 字节验证 deliverToRunning 写入)。 */
|
|
98
98
|
function makeStreamChild(): ChildProcess {
|
|
99
|
-
|
|
99
|
+
// exitCode/signalCode:真 ChildProcess 未退出时均为 null(deliverMessage [race-F5]
|
|
100
|
+
// 写后死进程检测读这两个字段,缺省 undefined 会被误判为已死触发 warn)。
|
|
101
|
+
return { stdin: new PassThrough(), exitCode: null, signalCode: null } as unknown as ChildProcess;
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
function readStdinLines(child: ChildProcess): unknown[] {
|
|
@@ -232,6 +234,41 @@ describe("deliverMessage (V2 决策 3 chatMode 统一投递)", () => {
|
|
|
232
234
|
expect(record.status).toBe("running");
|
|
233
235
|
});
|
|
234
236
|
|
|
237
|
+
// [race-F5] 写后死进程检测:热路径 write 同步成功(数据进内核缓冲)但子进程在读取前
|
|
238
|
+
// 已死(gate/idle kill 竞速)→ 消息将随缓冲静默丢弃。修复:写后检查 exitCode/signalCode,
|
|
239
|
+
// 已死则 warn 留证(含 runId 与消息类型),不抛错不重试(终态已由 kill 路径保证)。
|
|
240
|
+
it("[race-F5] 热路径写后子进程已死 → logger.warn 被记录(含 runId 与消息类型),不抛错", () => {
|
|
241
|
+
const child = makeStreamChild();
|
|
242
|
+
// 模拟 gate/idle kill 竞速:写 stdin 成功但子进程已死(SIGTERM 终止形态)
|
|
243
|
+
Object.assign(child, { signalCode: "SIGTERM" });
|
|
244
|
+
spawnedChildren.set(record.id, child);
|
|
245
|
+
|
|
246
|
+
expect(() => service.deliverMessage(record, "lost msg", true)).not.toThrow();
|
|
247
|
+
|
|
248
|
+
// 写入照常发生(热路径语义不变,不做二次分发)
|
|
249
|
+
const lines = readStdinLines(child);
|
|
250
|
+
expect(lines[0]).toMatchObject({ type: "prompt", message: "lost msg", streamingBehavior: "steer" });
|
|
251
|
+
// warn 留证:含 runId(record.id)与消息类型(steer)
|
|
252
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
253
|
+
expect.stringContaining(`child ${record.id} died around stdin write`),
|
|
254
|
+
expect.objectContaining({ msgType: "steer", signalCode: "SIGTERM" }),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("[race-F5] 活进程正常投递不触发死进程 warn(守卫不误报)", () => {
|
|
259
|
+
// loggerMock 是模块级共享 mock:清历史调用后再断言(防前一用例的 warn 干扰)
|
|
260
|
+
loggerMock.warn.mockClear();
|
|
261
|
+
const child = makeStreamChild();
|
|
262
|
+
spawnedChildren.set(record.id, child);
|
|
263
|
+
|
|
264
|
+
service.deliverMessage(record, "normal", false);
|
|
265
|
+
|
|
266
|
+
expect(loggerMock.warn).not.toHaveBeenCalledWith(
|
|
267
|
+
expect.stringContaining("died around stdin write"),
|
|
268
|
+
expect.anything(),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
235
272
|
it("热路径 disarm idle timer:arm 后 deliverMessage → timer 清除(防 turn 期间误杀)", () => {
|
|
236
273
|
const child = makeStreamChild();
|
|
237
274
|
spawnedChildren.set(record.id, child);
|
|
@@ -125,6 +125,17 @@ describe("mapToExecuteOptions (D-A2)", () => {
|
|
|
125
125
|
const result = mapToExecuteOptions(baseOpts);
|
|
126
126
|
expect(result.appendSystemPrompt).toBeUndefined();
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
it("maxTurns 透传(预算语义对齐:workflow agent() 可显式传 turn 上限)", () => {
|
|
130
|
+
const opts: AgentCallOpts = { ...baseOpts, maxTurns: 20 };
|
|
131
|
+
const result = mapToExecuteOptions(opts);
|
|
132
|
+
expect(result.maxTurns).toBe(20);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("maxTurns 不传 → undefined(不限 turn,不挂 turns 估算 watchdog)", () => {
|
|
136
|
+
const result = mapToExecuteOptions(baseOpts);
|
|
137
|
+
expect(result.maxTurns).toBeUndefined();
|
|
138
|
+
});
|
|
128
139
|
});
|
|
129
140
|
|
|
130
141
|
describe("mergeTimeoutSignal (D-A9)", () => {
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
getTotalUsage,
|
|
15
15
|
markReconstructedStatus,
|
|
16
16
|
nextRoundBaseTurnIndex,
|
|
17
|
+
deriveOutcome,
|
|
18
|
+
projectOutcome,
|
|
17
19
|
project,
|
|
18
20
|
projectLiveProgress,
|
|
19
21
|
snapshot,
|
|
@@ -886,6 +888,106 @@ describe("completeRecord", () => {
|
|
|
886
888
|
completeRecord(r, failedResult, "failed");
|
|
887
889
|
expect(r.error).toBe("oops");
|
|
888
890
|
});
|
|
891
|
+
|
|
892
|
+
// ── U3 C-outcome:completeRecord 唯一写入点冻结 outcome ──
|
|
893
|
+
|
|
894
|
+
it("[U3] 唯一写入点冻结 outcome:completed / failed / cancelled", () => {
|
|
895
|
+
const ok = makeRecord();
|
|
896
|
+
ok.status = "closed";
|
|
897
|
+
completeRecord(ok, SAMPLE_RESULT, "closed", "gc");
|
|
898
|
+
expect(ok.outcome).toBe("completed");
|
|
899
|
+
expect(ok.closedReason).toBe("gc");
|
|
900
|
+
|
|
901
|
+
const failed = makeRecord();
|
|
902
|
+
failed.status = "closed";
|
|
903
|
+
const failedResult: AgentResult = { ...SAMPLE_RESULT, success: false, error: "oops" };
|
|
904
|
+
completeRecord(failed, failedResult, "closed", "gc");
|
|
905
|
+
expect(failed.outcome).toBe("failed");
|
|
906
|
+
|
|
907
|
+
// 取消优先于 error(abort 合成 result 可能携带 error,取消语义优先)
|
|
908
|
+
const cancelled = makeRecord();
|
|
909
|
+
cancelled.status = "closed";
|
|
910
|
+
completeRecord(
|
|
911
|
+
cancelled,
|
|
912
|
+
{ ...SAMPLE_RESULT, success: false, error: "aborted by user" },
|
|
913
|
+
"closed",
|
|
914
|
+
"cancelled",
|
|
915
|
+
);
|
|
916
|
+
expect(cancelled.outcome).toBe("cancelled");
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it("[U3][D6 显式取舍] parent-shutdown 合成关闭(error='closed due to ...')→ outcome 'failed'", () => {
|
|
920
|
+
// disposeAllRecords 合成 result 恒写 success:false + error:"closed due to ${reason}"
|
|
921
|
+
// ——语义为「父进程关闭时子 agent 未完成即失败」,选定行为而非疏漏。
|
|
922
|
+
const r = makeRecord();
|
|
923
|
+
r.status = "closed";
|
|
924
|
+
completeRecord(
|
|
925
|
+
r,
|
|
926
|
+
{ ...SAMPLE_RESULT, success: false, error: "closed due to parent-shutdown" },
|
|
927
|
+
"closed",
|
|
928
|
+
"parent-shutdown",
|
|
929
|
+
);
|
|
930
|
+
expect(r.outcome).toBe("failed");
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it("[U3] patchFile 语义不变:completeRecord 不触碰 patchFile/result,仅新增 outcome", () => {
|
|
934
|
+
const r = makeRecord({ patchFile: "/tmp/patches/sa-x.patch" });
|
|
935
|
+
r.status = "closed";
|
|
936
|
+
completeRecord(r, SAMPLE_RESULT, "closed", "gc");
|
|
937
|
+
expect(r.patchFile).toBe("/tmp/patches/sa-x.patch");
|
|
938
|
+
expect(r.result).toBe("done");
|
|
939
|
+
expect(r.outcome).toBe("completed");
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// ============================================================
|
|
944
|
+
// deriveOutcome / projectOutcome(U3 C-outcome 单一权威派生)
|
|
945
|
+
// ============================================================
|
|
946
|
+
describe("deriveOutcome(单一权威终态派生)", () => {
|
|
947
|
+
it("completed:closed + 无 error(gc / user-close 正常完成)", () => {
|
|
948
|
+
expect(deriveOutcome("gc", undefined)).toBe("completed");
|
|
949
|
+
expect(deriveOutcome("user-close", undefined)).toBe("completed");
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it("failed:closed + error 非空(gc 失败)", () => {
|
|
953
|
+
expect(deriveOutcome("gc", "spawn EPIPE")).toBe("failed");
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("failed:parent-shutdown/parent-fork/parent-new 合成关闭(D6 显式取舍,勿改 cancelled)", () => {
|
|
957
|
+
expect(deriveOutcome("parent-shutdown", "closed due to parent-shutdown")).toBe("failed");
|
|
958
|
+
expect(deriveOutcome("parent-fork", "closed due to parent-fork")).toBe("failed");
|
|
959
|
+
expect(deriveOutcome("parent-new", "closed due to parent-new")).toBe("failed");
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it("cancelled:取消优先于 error(abort 合成 result 可能带 error)", () => {
|
|
963
|
+
expect(deriveOutcome("cancelled", undefined)).toBe("cancelled");
|
|
964
|
+
expect(deriveOutcome("cancelled", "aborted by user")).toBe("cancelled");
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it("空串 error 不构成 failed(与旧三处同构的 `record.error &&` truthy 判定逐字对齐)", () => {
|
|
968
|
+
expect(deriveOutcome("gc", "")).toBe("completed");
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
describe("projectOutcome(投影唯一出口)", () => {
|
|
973
|
+
it("running → undefined(终态语义不适用活跃态;即使误带 outcome 也拦截)", () => {
|
|
974
|
+
expect(projectOutcome({ status: "running" })).toBeUndefined();
|
|
975
|
+
expect(projectOutcome({ status: "running", outcome: "completed" })).toBeUndefined();
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("closed + 一等 outcome → 直读透传", () => {
|
|
979
|
+
expect(projectOutcome({ status: "closed", outcome: "failed" })).toBe("failed");
|
|
980
|
+
expect(projectOutcome({ status: "closed", outcome: "cancelled" })).toBe("cancelled");
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it("closed + 无 outcome(存量/重建 record)→ deriveOutcome(closedReason, error) 兑底", () => {
|
|
984
|
+
expect(projectOutcome({ status: "closed", closedReason: "gc", error: "boom" })).toBe("failed");
|
|
985
|
+
expect(projectOutcome({ status: "closed", closedReason: "cancelled" })).toBe("cancelled");
|
|
986
|
+
expect(projectOutcome({ status: "closed", closedReason: "gc" })).toBe("completed");
|
|
987
|
+
// 连 closedReason/error 都缺失的最旧存量:兜底为 completed(与旧 closedReason??'gc'
|
|
988
|
+
// 兑底显示语义一致)
|
|
989
|
+
expect(projectOutcome({ status: "closed" })).toBe("completed");
|
|
990
|
+
});
|
|
889
991
|
});
|
|
890
992
|
|
|
891
993
|
// ============================================================
|
|
@@ -928,6 +1030,14 @@ describe("projections", () => {
|
|
|
928
1030
|
expect(d.sessionFile).toBe("bg-1-abc.jsonl");
|
|
929
1031
|
});
|
|
930
1032
|
|
|
1033
|
+
it("[U3] project 投影携带 outcome:一等字段直读 / 存量兑底 / running undefined", () => {
|
|
1034
|
+
const done = makeRecord({ status: "closed", outcome: "completed", closedReason: "gc" });
|
|
1035
|
+
expect(project(done).outcome).toBe("completed");
|
|
1036
|
+
const legacy = makeRecord({ status: "closed", closedReason: "gc", error: "boom" });
|
|
1037
|
+
expect(project(legacy).outcome).toBe("failed");
|
|
1038
|
+
expect(project(makeRecord({ status: "running" })).outcome).toBeUndefined();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
931
1041
|
it("sessionFile is undefined when record.sessionFile unset", () => {
|
|
932
1042
|
const r = makeRecord();
|
|
933
1043
|
expect(project(r).sessionFile).toBeUndefined();
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// 显式 agent ref 失败必须报错(非静默降级)——三通道对称审查修复的 agent 通道验收。
|
|
2
|
+
//
|
|
3
|
+
// 修复背景:resolveIdentity 旧实现 getAgentConfig(loadByPath 无 require)对相对
|
|
4
|
+
// 路径/裸名/文件缺失一律返回 undefined → agentConfig undefined → resolveModel 静默
|
|
5
|
+
// 回落 override→主 agent model——用户点名 agent 却拿到无 systemPrompt/无工具白名单
|
|
6
|
+
// 的 general-purpose 形态,零反馈。修复后显式 ref 失败抛错(require:true 语义,
|
|
7
|
+
// 错误含 <available_subagents> 恢复指引,对齐 workflow name not found 反馈风格)。
|
|
8
|
+
//
|
|
9
|
+
// 本文件锁住:
|
|
10
|
+
// 1. 裸名/相对路径 agent ref → execute 同步 reject(Invalid agent ref),无 spawn、
|
|
11
|
+
// 无子 session、record 不落盘(buildSpawnArgs/spawn/appendEntry 零触达)
|
|
12
|
+
// 2. 绝对路径但文件不存在 → reject(not found or unreadable)
|
|
13
|
+
// 3. 对照:不传 agent(默认 general-purpose)与合法绝对路径 ref 均不误伤
|
|
14
|
+
//
|
|
15
|
+
// harness 复用 start-sync-model-guard.test.ts(真实 SubagentService + tmpdir +
|
|
16
|
+
// mock spawn),保证守卫位于同步裁决期、spawn 前置的结构性证据可断言。
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
24
|
+
|
|
25
|
+
vi.mock("node:child_process", async () => {
|
|
26
|
+
const actual = await import("node:child_process");
|
|
27
|
+
return {
|
|
28
|
+
...actual,
|
|
29
|
+
spawn: vi.fn(() => {
|
|
30
|
+
throw new Error("spawn must not be called on agent-ref rejection");
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// buildSpawnArgs 包装为 spy(保留真实实现)——「显式 ref 拒单不触达 spawn 参数组装」的承重断言。
|
|
36
|
+
vi.mock("../session-runner.ts", async (importOriginal) => {
|
|
37
|
+
const actual = await importOriginal<typeof import("../session-runner.ts")>();
|
|
38
|
+
return { ...actual, buildSpawnArgs: vi.fn(actual.buildSpawnArgs) };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
42
|
+
import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
|
|
43
|
+
import { buildSpawnArgs } from "../session-runner.ts";
|
|
44
|
+
import type { PiLike } from "../subagent-service.ts";
|
|
45
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
46
|
+
|
|
47
|
+
const mockSpawn = vi.mocked(spawn);
|
|
48
|
+
const mockBuildSpawnArgs = vi.mocked(buildSpawnArgs);
|
|
49
|
+
|
|
50
|
+
// ── 工具(start-sync-model-guard 同款)──
|
|
51
|
+
|
|
52
|
+
function makeModel(over: Partial<ModelInfo> = {}): ModelInfo {
|
|
53
|
+
return {
|
|
54
|
+
id: over.id ?? "GLM-5.3-Flash",
|
|
55
|
+
name: over.name ?? "GLM 5.3 Flash",
|
|
56
|
+
provider: over.provider ?? "zai-coding-cn",
|
|
57
|
+
reasoning: over.reasoning ?? false,
|
|
58
|
+
...over,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeRegistry(models: ModelInfo[]): ModelRegistryLike {
|
|
63
|
+
return {
|
|
64
|
+
getAvailable: () => models,
|
|
65
|
+
find: (provider, modelId) => models.find((m) => m.provider === provider && m.id === modelId),
|
|
66
|
+
hasConfiguredAuth: () => true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makePi(): PiLike {
|
|
71
|
+
return {
|
|
72
|
+
appendEntry: vi.fn(),
|
|
73
|
+
events: { emit: vi.fn() },
|
|
74
|
+
sendMessage: vi.fn(),
|
|
75
|
+
} as unknown as PiLike;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("显式 agent ref 失败报错(非静默降级 general-purpose)", () => {
|
|
79
|
+
let tmpDir: string;
|
|
80
|
+
let service: SubagentService;
|
|
81
|
+
let pi: PiLike;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
vi.clearAllMocks();
|
|
85
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sw-agent-ref-guard-"));
|
|
86
|
+
const modelService = new ModelConfigService({ agentDir: tmpDir, cwd: tmpDir });
|
|
87
|
+
modelService.initModel({
|
|
88
|
+
modelRegistry: makeRegistry([makeModel()]),
|
|
89
|
+
sessionId: "agent-ref-guard-session",
|
|
90
|
+
});
|
|
91
|
+
service = new SubagentService({ cwd: tmpDir, modelService });
|
|
92
|
+
pi = makePi();
|
|
93
|
+
service.initSession({ pi, sessionId: "agent-ref-guard-session" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
vi.restoreAllMocks();
|
|
98
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── 拒绝路径:显式 ref 失败 → 同步 reject + spawn 链路零触达 ──
|
|
102
|
+
|
|
103
|
+
it("裸名 agent ref('worker')→ execute 同步 reject(Invalid agent ref + 恢复指引)", async () => {
|
|
104
|
+
await expect(
|
|
105
|
+
service.execute({ task: "probe task", slug: "ref-guard", agent: "worker" }),
|
|
106
|
+
).rejects.toThrow(/Invalid agent ref: worker.*<available_subagents>/s);
|
|
107
|
+
|
|
108
|
+
// spawn 链路零触达 + record 未创建——非静默降级的结构性证据:
|
|
109
|
+
// 旧行为(静默 undefined)会继续走 record 创建(appendEntry 被调)后启动子进程。
|
|
110
|
+
expect(mockBuildSpawnArgs).not.toHaveBeenCalled();
|
|
111
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
112
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("相对路径 agent ref → 同样 reject(引用唯一形态 = 绝对路径)", async () => {
|
|
116
|
+
await expect(
|
|
117
|
+
service.execute({ task: "probe task", slug: "ref-guard", agent: "./agents/worker.md" }),
|
|
118
|
+
).rejects.toThrow(/Invalid agent ref/);
|
|
119
|
+
|
|
120
|
+
expect(mockBuildSpawnArgs).not.toHaveBeenCalled();
|
|
121
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("绝对路径但文件不存在 → reject(not found or unreadable),非静默降级", async () => {
|
|
125
|
+
const missing = path.join(tmpDir, "no-such-agent.md");
|
|
126
|
+
await expect(
|
|
127
|
+
service.execute({ task: "probe task", slug: "ref-guard", agent: missing }),
|
|
128
|
+
).rejects.toThrow(/not found or unreadable.*<available_subagents>/s);
|
|
129
|
+
|
|
130
|
+
expect(mockBuildSpawnArgs).not.toHaveBeenCalled();
|
|
131
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
132
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── 放行对照:两类合法语义不误伤 ──
|
|
136
|
+
|
|
137
|
+
it("不传 agent(默认 general-purpose 语义)→ 不触发 agent ref 守卫", async () => {
|
|
138
|
+
const promise = service.execute({
|
|
139
|
+
task: "probe task",
|
|
140
|
+
slug: "ref-default-ok",
|
|
141
|
+
ctxModel: makeModel(),
|
|
142
|
+
});
|
|
143
|
+
// 默认语义下 identity 解析必须放行(execute 可能因 mock 环境子进程收尾而 reject,
|
|
144
|
+
// 但错误不能是 agent ref 拒绝);record 已创建(appendEntry 发生)。
|
|
145
|
+
try {
|
|
146
|
+
await promise;
|
|
147
|
+
} catch (e) {
|
|
148
|
+
expect((e as Error).message).not.toMatch(/Invalid agent ref/);
|
|
149
|
+
expect((e as Error).message).not.toMatch(/not found or unreadable/);
|
|
150
|
+
}
|
|
151
|
+
expect(pi.appendEntry).toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("显式合法绝对路径 agent .md → 守卫零误伤,identity 放行", async () => {
|
|
155
|
+
const agentFile = path.join(tmpDir, "worker.md");
|
|
156
|
+
fs.writeFileSync(agentFile, "You are a test worker agent.");
|
|
157
|
+
const promise = service.execute({
|
|
158
|
+
task: "probe task",
|
|
159
|
+
slug: "ref-valid-ok",
|
|
160
|
+
agent: agentFile,
|
|
161
|
+
ctxModel: makeModel(),
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
await promise;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
expect((e as Error).message).not.toMatch(/Invalid agent ref/);
|
|
167
|
+
expect((e as Error).message).not.toMatch(/not found or unreadable/);
|
|
168
|
+
}
|
|
169
|
+
expect(pi.appendEntry).toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
// src/__tests__/format-schema-instruction.test.ts
|
|
1
|
+
// src/execution/__tests__/format-schema-instruction.test.ts
|
|
2
2
|
//
|
|
3
3
|
// 锁定 formatSchemaInstruction 契约:构造 schema enforcement 的 MANDATORY 指令。
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// [审查项#2] 注入链变更:指令仅经 agent-opts-resolver 进 appendSystemPrompt
|
|
5
|
+
// (ASP 单点),runSpawn 的 task + instruction 后缀拼接已删除——本测试随之改锁
|
|
6
|
+
// resolver 导出的同名函数。一旦漏掉 "structured-output" 关键词或 JSON 序列化漂移,
|
|
7
|
+
// schema 模式会静默失效——agent 可能直接把 JSON 写进文本响应。
|
|
8
|
+
//
|
|
9
|
+
// [审查项#4] 锁定 AP 告知句:注入侧校验用 additionalProperties:false 收窄后的
|
|
10
|
+
// parameters,指令必须前置告知「schema 外字段被拒」,否则拒绝显得凭空。
|
|
7
11
|
//
|
|
8
12
|
// 纯函数测试:不依赖 Pi 运行时、不 spawn 进程、不 mock。只 import 被测函数。
|
|
9
13
|
import { describe, expect, it } from "vitest";
|
|
10
14
|
|
|
11
|
-
import { formatSchemaInstruction } from "
|
|
15
|
+
import { formatSchemaInstruction } from "../../orchestration/agent-opts-resolver.ts";
|
|
12
16
|
|
|
13
17
|
describe("formatSchemaInstruction", () => {
|
|
14
18
|
// ── 指令文本契约 ──────────────────────────────────────────────
|
|
@@ -21,49 +25,78 @@ describe("formatSchemaInstruction", () => {
|
|
|
21
25
|
it("emits a MANDATORY structured-output directive (not free-form JSON)", () => {
|
|
22
26
|
const out = formatSchemaInstruction({ type: "object" });
|
|
23
27
|
expect(out).toContain("MANDATORY");
|
|
24
|
-
expect(out).toContain("
|
|
25
|
-
expect(out).toContain("
|
|
26
|
-
expect(out).toContain("Do NOT output the JSON directly");
|
|
28
|
+
expect(out).toContain("must be calling the `structured-output` tool");
|
|
29
|
+
expect(out).toContain("Do NOT output JSON in your text response");
|
|
27
30
|
});
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
it("announces that fields outside the schema are rejected (AP 告知,审查项#4)", () => {
|
|
33
|
+
const out = formatSchemaInstruction({ type: "object" });
|
|
34
|
+
expect(out).toContain("Fields not defined in this schema are rejected");
|
|
35
|
+
expect(out).toContain("do not add extra fields");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("author-declared additionalProperties → weak wording (AP 条件化:声明时作者 schema 管辖额外字段)", () => {
|
|
39
|
+
// D4 只在「未声明」时注入 false;作者显式声明 true 时额外字段实际放行——
|
|
40
|
+
// 无条件「一律拒绝」强承诺与参数层行为不符,改用弱承诺措辞。
|
|
41
|
+
const out = formatSchemaInstruction({ type: "object", additionalProperties: true });
|
|
42
|
+
expect(out).not.toContain("Fields not defined in this schema are rejected");
|
|
43
|
+
expect(out).toContain("follow this schema's own additionalProperties declaration");
|
|
44
|
+
});
|
|
30
45
|
|
|
31
|
-
|
|
46
|
+
// ── schema 序列化(compact:与 schemaEnv 复用同串,IF7 #13)───────
|
|
47
|
+
|
|
48
|
+
it("embeds the schema as compact JSON inside a fenced block", () => {
|
|
32
49
|
const schema: Record<string, unknown> = {
|
|
33
50
|
type: "object",
|
|
34
51
|
properties: { name: { type: "string" } },
|
|
35
52
|
};
|
|
36
53
|
const out = formatSchemaInstruction(schema);
|
|
37
|
-
// 必须包含 JSON.stringify(schema
|
|
38
|
-
expect(out).toContain(JSON.stringify(schema
|
|
54
|
+
// 必须包含 JSON.stringify(schema) 的完整结果(compact,无缩进)
|
|
55
|
+
expect(out).toContain(JSON.stringify(schema));
|
|
39
56
|
expect(out).toContain("```json");
|
|
40
57
|
expect(out).toContain("```");
|
|
41
|
-
// indent=2 的可观察证据:属性键前有恰好两个空格(object 第一层缩进)
|
|
42
|
-
expect(out).toContain('\n "type": "object"');
|
|
43
58
|
});
|
|
44
59
|
|
|
45
60
|
it("locks the full output structure for a minimal schema", () => {
|
|
46
61
|
const out = formatSchemaInstruction({ type: "object" });
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
62
|
+
// 完整结构快照——任何指令措辞/顺序/序列化漂移都会被捕获。
|
|
63
|
+
// [HISTORICAL] runner task 后缀双重注入删除后,本函数迁至 resolver 成为唯一
|
|
64
|
+
// 文案源;JSON 从 pretty(indent=2)改为 compact(与 schemaEnv 同串复用)。
|
|
50
65
|
expect(out).toBe(
|
|
51
66
|
[
|
|
52
|
-
"MANDATORY: Structured Output Requirement",
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
67
|
+
"## MANDATORY: Structured Output Requirement",
|
|
68
|
+
"",
|
|
69
|
+
"This task requires structured output.",
|
|
70
|
+
"Your FINAL action must be calling the `structured-output` tool.",
|
|
71
|
+
"",
|
|
72
|
+
"Your call arguments ARE the result data itself — the tool's parameter schema IS the required shape of your result.",
|
|
73
|
+
"Your result must conform to this schema:",
|
|
58
74
|
"```json",
|
|
59
|
-
'{',
|
|
60
|
-
' "type": "object"',
|
|
61
|
-
'}',
|
|
75
|
+
'{"type":"object"}',
|
|
62
76
|
"```",
|
|
77
|
+
"",
|
|
78
|
+
"Rules:",
|
|
79
|
+
"- Call the structured-output tool with your result data as its arguments. The system validates them against the schema above automatically.",
|
|
80
|
+
"- Do NOT output JSON in your text response — use the structured-output tool.",
|
|
81
|
+
"- Do NOT skip this step. The structured-output call IS your result.",
|
|
82
|
+
"- Complete all other work FIRST, then call structured-output as the last action.",
|
|
83
|
+
"- Fields not defined in this schema are rejected — do not add extra fields.",
|
|
63
84
|
].join("\n"),
|
|
64
85
|
);
|
|
65
86
|
});
|
|
66
87
|
|
|
88
|
+
it("locks the single-parameter wording: arguments ARE the data coexists with the mandatory tool call", () => {
|
|
89
|
+
const out = formatSchemaInstruction({ type: "object" });
|
|
90
|
+
// 新口径双支柱必须共存:① 参数即数据(arguments ARE the data)② 必须调用工具
|
|
91
|
+
expect(out).toContain("arguments ARE the result data itself");
|
|
92
|
+
expect(out).toContain("parameter schema IS the required shape of your result");
|
|
93
|
+
expect(out).toContain("must be calling the `structured-output` tool");
|
|
94
|
+
// 旧双参数警告语义不得回流(工具已是单参数形态,警告失去对象)
|
|
95
|
+
expect(out).not.toMatch(/do NOT pass a .schema. parameter/i);
|
|
96
|
+
expect(out).not.toContain("ONLY the `data` parameter");
|
|
97
|
+
expect(out).not.toContain("enforced by the system");
|
|
98
|
+
});
|
|
99
|
+
|
|
67
100
|
// ── 特殊字符转义(注入风险路径)──────────────────────────────
|
|
68
101
|
|
|
69
102
|
it("escapes double quotes inside schema string values", () => {
|
|
@@ -102,8 +135,9 @@ describe("formatSchemaInstruction", () => {
|
|
|
102
135
|
it("preserves null values in schema", () => {
|
|
103
136
|
const schema: Record<string, unknown> = { default: null };
|
|
104
137
|
const out = formatSchemaInstruction(schema);
|
|
105
|
-
// JSON.stringify 对 null 保留字面 "null"(不会 omit
|
|
106
|
-
|
|
138
|
+
// JSON.stringify 对 null 保留字面 "null"(不会 omit 键,也不会变字符串)。
|
|
139
|
+
// compact 序列化无空格:'"default":null'
|
|
140
|
+
expect(out).toContain('"default":null');
|
|
107
141
|
});
|
|
108
142
|
|
|
109
143
|
it("serializes nested objects and arrays", () => {
|
|
@@ -116,10 +150,7 @@ describe("formatSchemaInstruction", () => {
|
|
|
116
150
|
},
|
|
117
151
|
};
|
|
118
152
|
const out = formatSchemaInstruction(schema);
|
|
119
|
-
expect(out).toContain(JSON.stringify(schema
|
|
120
|
-
// 嵌套结构 indent 正确(第二层 4 空格)
|
|
121
|
-
expect(out).toContain(' "name": {');
|
|
122
|
-
expect(out).toContain(' "type": "string"');
|
|
153
|
+
expect(out).toContain(JSON.stringify(schema));
|
|
123
154
|
});
|
|
124
155
|
|
|
125
156
|
// ── 确定性 ────────────────────────────────────────────────────
|