@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
|
@@ -89,6 +89,10 @@ export class FakeChild extends EventEmitter {
|
|
|
89
89
|
stdin = new PassThrough();
|
|
90
90
|
killed = false;
|
|
91
91
|
killSignal: string | undefined;
|
|
92
|
+
// [race-F4] SIGKILL 升级判定读真 ChildProcess 的 exitCode/signalCode(未退出时均为
|
|
93
|
+
// null)——FakeChild 同形提供,测试可赋值模拟已死进程(如 deliverMessage 写后死检测)。
|
|
94
|
+
exitCode: number | null = null;
|
|
95
|
+
signalCode: string | null = null;
|
|
92
96
|
kill(sig?: string): boolean {
|
|
93
97
|
this.killed = true;
|
|
94
98
|
this.killSignal = sig;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// 但**没有断言 setUiRequestHandler 被调用**——本测试补这个断言,且覆盖 existing 路径
|
|
19
19
|
// (既有测试 fixed getSubagentService() => null,只能走 new 分支)。
|
|
20
20
|
|
|
21
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
22
22
|
|
|
23
23
|
// ── mock modules(在 import 前声明;路径相对 src/execution/__tests/) ──
|
|
24
24
|
//
|
|
@@ -191,8 +191,9 @@ import { WorkflowRun } from "../../orchestration/models/workflow-run.ts";
|
|
|
191
191
|
// ── helpers ──
|
|
192
192
|
|
|
193
193
|
/** 创建可观察的 mock ExtensionAPI,捕获 session_start / session_shutdown handler。
|
|
194
|
-
* Proxy 兜底:未显式处理的 pi.xxx 返回 noop,避免抛错。
|
|
195
|
-
|
|
194
|
+
* Proxy 兜底:未显式处理的 pi.xxx 返回 noop,避免抛错。
|
|
195
|
+
* spies(可选):U2 通知账本用例注入 appendEntry / sendMessage 观察桩。 */
|
|
196
|
+
function createMockPi(spies?: { appendEntry?: (customType: string, data: unknown) => void; sendMessage?: (message: unknown, options?: unknown) => void }): {
|
|
196
197
|
pi: ExtensionAPI;
|
|
197
198
|
getSessionStartHandler: () => ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
|
|
198
199
|
getSessionShutdownHandler: () => ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
|
|
@@ -216,7 +217,8 @@ function createMockPi(): {
|
|
|
216
217
|
};
|
|
217
218
|
}
|
|
218
219
|
if (prop === "events") return events;
|
|
219
|
-
if (prop === "appendEntry") return noop;
|
|
220
|
+
if (prop === "appendEntry") return spies?.appendEntry ?? noop;
|
|
221
|
+
if (prop === "sendMessage") return spies?.sendMessage ?? noop;
|
|
220
222
|
if (prop === "registerMessageRenderer") return noop;
|
|
221
223
|
return noop;
|
|
222
224
|
},
|
|
@@ -228,14 +230,16 @@ function createMockPi(): {
|
|
|
228
230
|
};
|
|
229
231
|
}
|
|
230
232
|
|
|
231
|
-
/** 最小 ExtensionContext mock。mode 由参数控制(决定 handler 注入行为)。
|
|
232
|
-
|
|
233
|
+
/** 最小 ExtensionContext mock。mode 由参数控制(决定 handler 注入行为)。
|
|
234
|
+
* entries(可选):U2 通知账本用例注入 sessionManager.getEntries 返回值
|
|
235
|
+
* (模拟重启前落盘的 ledger/ack entry)。 */
|
|
236
|
+
function createMockCtx(mode: "tui" | "rpc" | "json" | "print", entries?: unknown[]): Record<string, unknown> {
|
|
233
237
|
const sessionManager = {
|
|
234
238
|
getSessionId: () => "session-inject-1",
|
|
235
239
|
getSessionFile: () => "/home/user/.pi/agent/sessions/session-inject-1.jsonl",
|
|
236
240
|
getSessionDir: () => "/home/user/.pi/agent/sessions",
|
|
237
241
|
getCwd: () => "/home/user/project",
|
|
238
|
-
getEntries: () => [],
|
|
242
|
+
getEntries: () => entries ?? [],
|
|
239
243
|
getBranch: () => [],
|
|
240
244
|
getLeafId: () => null,
|
|
241
245
|
getLeafEntry: () => undefined,
|
|
@@ -256,6 +260,7 @@ function createMockCtx(mode: "tui" | "rpc" | "json" | "print"): Record<string, u
|
|
|
256
260
|
model: undefined,
|
|
257
261
|
sessionManager,
|
|
258
262
|
ui,
|
|
263
|
+
isIdle: () => true,
|
|
259
264
|
};
|
|
260
265
|
}
|
|
261
266
|
|
|
@@ -591,3 +596,77 @@ describe("W-3: done run 淘汰接线(loadAll 裁剪 + onRunDone 裁剪)", ()
|
|
|
591
596
|
}
|
|
592
597
|
});
|
|
593
598
|
});
|
|
599
|
+
|
|
600
|
+
// ============================================================
|
|
601
|
+
// [U2] session_start 通知账本装配 + 重启恢复钩子(设计 D4)
|
|
602
|
+
// ============================================================
|
|
603
|
+
//
|
|
604
|
+
// mock session 文件(ctx.sessionManager.getEntries)含 ledger/ack entry →
|
|
605
|
+
// session_start handler 装配账本并重放差集:未销账号经 pi.sendMessage({triggerTurn:true})
|
|
606
|
+
// 单通道重投;已销账零重发。bind 顺序在 service.initSession 之前(notifier.notify
|
|
607
|
+
// 经模块级绑定消费账本)。
|
|
608
|
+
|
|
609
|
+
import {
|
|
610
|
+
_resetNotifyLedgerForTest,
|
|
611
|
+
NOTIFY_ACK_CUSTOM_TYPE,
|
|
612
|
+
NOTIFY_CUSTOM_TYPE,
|
|
613
|
+
NOTIFY_LEDGER_CUSTOM_TYPE,
|
|
614
|
+
} from "../notify-ledger.ts";
|
|
615
|
+
|
|
616
|
+
describe("session_start 通知账本恢复钩子(U2 B-ledger)", () => {
|
|
617
|
+
afterEach(() => {
|
|
618
|
+
_resetNotifyLedgerForTest();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("mock session 含 ledger/ack entry → 未销账号重放(单通道 triggerTurn),已销账零重发", async () => {
|
|
622
|
+
const appendEntryCalls: Array<{ customType: string; data: unknown }> = [];
|
|
623
|
+
const sendMessageCalls: Array<{ message: unknown; options: unknown }> = [];
|
|
624
|
+
const { pi, getSessionStartHandler } = createMockPi({
|
|
625
|
+
appendEntry: (customType, data) => {
|
|
626
|
+
appendEntryCalls.push({ customType, data });
|
|
627
|
+
},
|
|
628
|
+
sendMessage: (message, options) => {
|
|
629
|
+
sendMessageCalls.push({ message, options });
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
subagentsExtension(pi);
|
|
633
|
+
|
|
634
|
+
// 模拟重启前落盘:2 条 ledger entry(s-a 已销账 + s-b 未销账)+ 1 条 ack
|
|
635
|
+
const entries: unknown[] = [
|
|
636
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a", content: "ca", record: { notifyId: "s-a" } } },
|
|
637
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-b", content: "cb", record: { notifyId: "s-b" } } },
|
|
638
|
+
{ type: "custom", customType: NOTIFY_ACK_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a" } },
|
|
639
|
+
];
|
|
640
|
+
const handler = getSessionStartHandler();
|
|
641
|
+
expect(handler).toBeDefined();
|
|
642
|
+
await handler!({ type: "session_start" }, createMockCtx("rpc", entries));
|
|
643
|
+
|
|
644
|
+
// 未销账 s-b 单条重放:sendMessage 恰 1 次,options 恰 {triggerTurn:true}
|
|
645
|
+
expect(sendMessageCalls).toHaveLength(1);
|
|
646
|
+
expect(sendMessageCalls[0]!.options).toEqual({ triggerTurn: true });
|
|
647
|
+
const sent = sendMessageCalls[0]!.message as { customType: string; content: string; details?: { notifyId?: string } };
|
|
648
|
+
expect(sent.customType).toBe(NOTIFY_CUSTOM_TYPE);
|
|
649
|
+
expect(sent.content).toBe("cb");
|
|
650
|
+
expect(sent.details?.notifyId).toBe("s-b");
|
|
651
|
+
// 已销账 s-a 零重发(不追加新 ledger entry,不重投)
|
|
652
|
+
expect(appendEntryCalls.filter((c) => c.customType === NOTIFY_LEDGER_CUSTOM_TYPE)).toHaveLength(0);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("全部已销账(差集为空)→ 零重放零发送", async () => {
|
|
656
|
+
const sendMessageCalls: unknown[] = [];
|
|
657
|
+
const { pi, getSessionStartHandler } = createMockPi({
|
|
658
|
+
sendMessage: () => {
|
|
659
|
+
sendMessageCalls.push({});
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
subagentsExtension(pi);
|
|
663
|
+
|
|
664
|
+
const entries: unknown[] = [
|
|
665
|
+
{ type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-x", content: "cx", record: { notifyId: "s-x" } } },
|
|
666
|
+
{ type: "custom", customType: NOTIFY_ACK_CUSTOM_TYPE, data: { v: 1, notifyId: "s-x" } },
|
|
667
|
+
];
|
|
668
|
+
await getSessionStartHandler()!({ type: "session_start" }, createMockCtx("rpc", entries));
|
|
669
|
+
|
|
670
|
+
expect(sendMessageCalls).toHaveLength(0);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
@@ -30,11 +30,15 @@ describe("lifecycle-manager — V2 §5.2 模块 1", () => {
|
|
|
30
30
|
beforeEach(() => {
|
|
31
31
|
vi.useFakeTimers();
|
|
32
32
|
_resetLifecycleState();
|
|
33
|
+
// [F-4 同源修复] env 隔离:「默认超时」用例依赖 XYZ_SUBAGENT_IDLE_TIMEOUT_MS
|
|
34
|
+
// 未设基线(getEnvIdleTimeoutMs 会覆盖 DEFAULT),宿主 export 即假红。空串 = 未设。
|
|
35
|
+
vi.stubEnv("XYZ_SUBAGENT_IDLE_TIMEOUT_MS", "");
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
afterEach(() => {
|
|
36
39
|
_resetLifecycleState();
|
|
37
40
|
vi.useRealTimers();
|
|
41
|
+
vi.unstubAllEnvs();
|
|
38
42
|
});
|
|
39
43
|
|
|
40
44
|
// ============================================================
|
|
@@ -92,6 +96,48 @@ describe("lifecycle-manager — V2 §5.2 模块 1", () => {
|
|
|
92
96
|
vi.advanceTimersByTime(1);
|
|
93
97
|
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
94
98
|
});
|
|
99
|
+
|
|
100
|
+
it("显式禁用值(0/负数)→ 不挂 timer(预算语义对齐:idle GC 可显式关闭;旧实现 0 落成 setTimeout(0) 立即 kill)", () => {
|
|
101
|
+
const onTimeout = vi.fn();
|
|
102
|
+
armIdleTimer("sa-disable-0", onTimeout, 0);
|
|
103
|
+
expect(hasIdleTimer("sa-disable-0")).toBe(false);
|
|
104
|
+
armIdleTimer("sa-disable-neg", onTimeout, -1);
|
|
105
|
+
expect(hasIdleTimer("sa-disable-neg")).toBe(false);
|
|
106
|
+
|
|
107
|
+
vi.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT_MS * 2);
|
|
108
|
+
expect(onTimeout).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("显式禁用值顺带 disarm 已有 armed timer(禁用不形同虚设)", () => {
|
|
112
|
+
const onTimeout = vi.fn();
|
|
113
|
+
armIdleTimer("sa-disable-late", onTimeout, 1000);
|
|
114
|
+
expect(hasIdleTimer("sa-disable-late")).toBe(true);
|
|
115
|
+
armIdleTimer("sa-disable-late", onTimeout, 0);
|
|
116
|
+
expect(hasIdleTimer("sa-disable-late")).toBe(false);
|
|
117
|
+
vi.advanceTimersByTime(2000);
|
|
118
|
+
expect(onTimeout).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// [U1] setTimeout 2^31-1 溢出 fail-fast:溢出 delay 被 Node 置 1ms 立即触发
|
|
122
|
+
//(「长空闲保活」变「立即 kill」),arm 入口拦截且错误含上限值与恢复指引。
|
|
123
|
+
// 显式禁用通道(<=0)不受影响(上方用例已锁)。
|
|
124
|
+
it("idleTimeoutMs 溢出(>2^31-1)→ fail-fast throw,不挂 timer", () => {
|
|
125
|
+
const onTimeout = vi.fn();
|
|
126
|
+
expect(() => armIdleTimer("sa-overflow", onTimeout, 3_000_000_000)).toThrowError(/2147483647/);
|
|
127
|
+
expect(() => armIdleTimer("sa-overflow", onTimeout, Number.MAX_SAFE_INTEGER)).toThrowError(
|
|
128
|
+
/Recovery/,
|
|
129
|
+
);
|
|
130
|
+
expect(hasIdleTimer("sa-overflow")).toBe(false);
|
|
131
|
+
vi.advanceTimersByTime(3_000_000_000);
|
|
132
|
+
expect(onTimeout).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("env XYZ_SUBAGENT_IDLE_TIMEOUT_MS 溢出 → fail-fast throw(arm 入口统一拦截)", () => {
|
|
136
|
+
vi.stubEnv("XYZ_SUBAGENT_IDLE_TIMEOUT_MS", "3000000000");
|
|
137
|
+
const onTimeout = vi.fn();
|
|
138
|
+
expect(() => armIdleTimer("sa-env-overflow", onTimeout)).toThrowError(/2147483647/);
|
|
139
|
+
expect(hasIdleTimer("sa-env-overflow")).toBe(false);
|
|
140
|
+
});
|
|
95
141
|
});
|
|
96
142
|
|
|
97
143
|
// ============================================================
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// src/execution/__tests__/list-fields.test.ts
|
|
2
2
|
//
|
|
3
|
-
// [v4 A-6] recordToListItem
|
|
3
|
+
// [v4 A-6] recordToListItem 字段派生单测;[U3 C-outcome] closedReason 改锚 outcome。
|
|
4
4
|
//
|
|
5
|
-
// 验证 list
|
|
5
|
+
// 验证 list 输出的字段派生正确:
|
|
6
6
|
// - parent:从 record.parentRecordId 派生(顶层 record → undefined)
|
|
7
7
|
// - resumable:从 isResumable 派生(running && 无活进程句柄)
|
|
8
|
-
// -
|
|
8
|
+
// - outcome:一等终态语义(projectOutcome 唯一出口);closedReason 退出对外 JSON,
|
|
9
|
+
// 存量/重建 record(无 outcome 字段)由 deriveOutcome(closedReason, error) 兜底
|
|
9
10
|
//
|
|
10
11
|
// Mock 策略:vi.mock lifecycle-predicates 的 isResumable,控制其返回值模拟「有/无
|
|
11
12
|
// 活进程句柄」两种 running 子态。isResumable 真实逻辑(running && !hasLiveProcessHandle)
|
|
@@ -21,7 +22,7 @@ vi.mock("../../execution/lifecycle-predicates.ts", () => ({
|
|
|
21
22
|
|
|
22
23
|
import { recordToListItem } from "../../interface/subagent-actions.ts";
|
|
23
24
|
import { isResumable } from "../../execution/lifecycle-predicates.ts";
|
|
24
|
-
import type {
|
|
25
|
+
import type { SubagentRecord } from "../types.ts";
|
|
25
26
|
|
|
26
27
|
// ── SubagentRecord stub 工厂(最小合法 record) ──
|
|
27
28
|
|
|
@@ -51,7 +52,7 @@ function makeRecord(over: Partial<SubagentRecord> = {}): SubagentRecord {
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
describe("recordToListItem — parent/resumable
|
|
55
|
+
describe("recordToListItem — parent/resumable (v4 A-6)", () => {
|
|
55
56
|
// ═══ parent:从 record.parentRecordId 派生 ═══
|
|
56
57
|
|
|
57
58
|
it("parent:嵌套 record(parentRecordId='sa-A')→ parent='sa-A'", () => {
|
|
@@ -84,26 +85,56 @@ describe("recordToListItem — parent/resumable/closedReason (v4 A-6)", () => {
|
|
|
84
85
|
expect(recordToListItem(rec).resumable).toBe(false);
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
// ═══
|
|
88
|
+
// ═══ outcome:一等终态披露(U3 C-outcome,projectOutcome 唯一出口)═══
|
|
88
89
|
|
|
89
|
-
it("
|
|
90
|
+
it("outcome:closed + closedReason='parent-fork' + 合成 error → 'failed'(D6 显式取舍:父进程关闭未完成即失败,勿改 cancelled)", () => {
|
|
90
91
|
const rec = makeRecord({
|
|
91
92
|
status: "closed",
|
|
92
|
-
closedReason: "parent-fork"
|
|
93
|
+
closedReason: "parent-fork",
|
|
94
|
+
error: "closed due to parent-fork",
|
|
93
95
|
});
|
|
94
|
-
expect(recordToListItem(rec).
|
|
96
|
+
expect(recordToListItem(rec).outcome).toBe("failed");
|
|
95
97
|
});
|
|
96
98
|
|
|
97
|
-
it("
|
|
99
|
+
it("outcome:closed + closedReason='cancelled' → 'cancelled'(取消优先于 error)", () => {
|
|
98
100
|
const rec = makeRecord({
|
|
99
101
|
status: "closed",
|
|
100
|
-
closedReason: "
|
|
102
|
+
closedReason: "cancelled",
|
|
103
|
+
error: "aborted",
|
|
101
104
|
});
|
|
102
|
-
expect(recordToListItem(rec).
|
|
105
|
+
expect(recordToListItem(rec).outcome).toBe("cancelled");
|
|
103
106
|
});
|
|
104
107
|
|
|
105
|
-
it("
|
|
108
|
+
it("outcome:closed + gc 正常完成(存量形态,无 outcome 字段、无 error)→ 兑底派生 'completed'", () => {
|
|
109
|
+
const rec = makeRecord({ status: "closed", closedReason: "gc" });
|
|
110
|
+
expect(recordToListItem(rec).outcome).toBe("completed");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("outcome:closed + gc + error(存量失败形态)→ 兑底派生 'failed'", () => {
|
|
114
|
+
const rec = makeRecord({ status: "closed", closedReason: "gc", error: "spawn EPIPE" });
|
|
115
|
+
expect(recordToListItem(rec).outcome).toBe("failed");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("outcome:一等字段存在时直读透传(不重推导)", () => {
|
|
119
|
+
const rec = makeRecord({ status: "closed", outcome: "failed", closedReason: "gc" });
|
|
120
|
+
expect(recordToListItem(rec).outcome).toBe("failed");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("outcome:running record → undefined(终态语义不适用活跃态)", () => {
|
|
106
124
|
const rec = makeRecord({ status: "running" });
|
|
107
|
-
expect(recordToListItem(rec).
|
|
125
|
+
expect(recordToListItem(rec).outcome).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("closedReason 退出对外 JSON:list item 不再携带(内部诊断字段),outcome 字段在位", () => {
|
|
129
|
+
const rec = makeRecord({ status: "closed", closedReason: "gc", error: "boom" });
|
|
130
|
+
const item = recordToListItem(rec);
|
|
131
|
+
expect("closedReason" in item).toBe(false);
|
|
132
|
+
const parsed = JSON.parse(JSON.stringify(item)) as Record<string, unknown>;
|
|
133
|
+
expect("closedReason" in parsed).toBe(false);
|
|
134
|
+
expect(parsed.outcome).toBe("failed");
|
|
135
|
+
// 旧字段保留(向后兼容)
|
|
136
|
+
expect(parsed.status).toBe("closed");
|
|
137
|
+
expect(parsed.state).toBe("ended");
|
|
138
|
+
expect(parsed.mode).toBe("background");
|
|
108
139
|
});
|
|
109
140
|
});
|
|
@@ -97,7 +97,7 @@ describe("resolveModel — explicit override failures throw (no silent fallback)
|
|
|
97
97
|
const reg = makeRegistry([]);
|
|
98
98
|
expect(() =>
|
|
99
99
|
resolveModel(undefined, reg, { model: "x/nonexistent" }, ctxModel),
|
|
100
|
-
).toThrow(/not
|
|
100
|
+
).toThrow(/is not a registry entry/);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
it("paramOverride.model found but auth missing → throws auth-specific message", () => {
|
|
@@ -117,7 +117,7 @@ describe("resolveModel — explicit override failures throw (no silent fallback)
|
|
|
117
117
|
undefined,
|
|
118
118
|
ctxModel,
|
|
119
119
|
),
|
|
120
|
-
).toThrow(/not
|
|
120
|
+
).toThrow(/is not a registry entry/);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
it("no override, no agentConfig.model, no ctxModel → throws listing available", () => {
|
|
@@ -132,7 +132,7 @@ describe("resolveModel — explicit override failures throw (no silent fallback)
|
|
|
132
132
|
const reg = makeRegistry([]);
|
|
133
133
|
expect(() =>
|
|
134
134
|
resolveModel(undefined, reg, { model: "no-slash" }, ctxModel),
|
|
135
|
-
).toThrow(/not
|
|
135
|
+
).toThrow(/is not a registry entry/);
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
138
|
|
|
@@ -264,7 +264,7 @@ describe('resolveModel — strips ":thinkingLevel" suffix from model string (A)'
|
|
|
264
264
|
// ":foo" 不是合法 thinking level,不剥离 → 查不到 → 抛 not found
|
|
265
265
|
const m = makeModel({ id: "m1", provider: "p", reasoning: false });
|
|
266
266
|
const reg = makeRegistry([m]);
|
|
267
|
-
expect(() => resolveModel(undefined, reg, { model: "p/m1:foo" })).toThrow(/not
|
|
267
|
+
expect(() => resolveModel(undefined, reg, { model: "p/m1:foo" })).toThrow(/is not a registry entry/);
|
|
268
268
|
});
|
|
269
269
|
|
|
270
270
|
it('suffix-stripped resolve still respects explicit thinkingLevel param', () => {
|
|
@@ -291,7 +291,7 @@ describe("resolveModel — not-found error suggests similar models (B)", () => {
|
|
|
291
291
|
} catch (e) {
|
|
292
292
|
msg = (e as Error).message;
|
|
293
293
|
}
|
|
294
|
-
expect(msg).toMatch(/not
|
|
294
|
+
expect(msg).toMatch(/is not a registry entry/);
|
|
295
295
|
expect(msg).toMatch(/deepseek-router\/ds-pro/); // 建议列表含正确拼写
|
|
296
296
|
});
|
|
297
297
|
|
|
@@ -351,6 +351,58 @@ describe("availableThinkingLevels", () => {
|
|
|
351
351
|
});
|
|
352
352
|
});
|
|
353
353
|
|
|
354
|
+
// ============================================================
|
|
355
|
+
// [U1 ModelRef 全等裁决] 孪生守卫(D1 规则④,两路径共用)
|
|
356
|
+
// ============================================================
|
|
357
|
+
|
|
358
|
+
describe("resolveModel — U1 twin guard (D1 rule ④)", () => {
|
|
359
|
+
/** 含大小写孪生的 registry 快照(模拟 models-store 刷新后大小写两形态并存)。 */
|
|
360
|
+
function makeTwinRegistry(): ModelRegistryLike {
|
|
361
|
+
return makeRegistry([
|
|
362
|
+
makeModel({ id: "GLM-5.3-Flash", provider: "zai-coding-cn" }),
|
|
363
|
+
makeModel({ id: "glm-5.3-flash", provider: "zai-coding-cn" }),
|
|
364
|
+
]);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
it("显式入参全等命中但 registry 含孪生 → 拒单(ambiguous,不代改输入)", () => {
|
|
368
|
+
expect(() =>
|
|
369
|
+
resolveModel(undefined, makeTwinRegistry(), { model: "zai-coding-cn/GLM-5.3-Flash" }, ctxModel),
|
|
370
|
+
).toThrow(/ambiguous case variants/);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("ctxModel 继承路径孪生守卫同等生效(D2 豁免的只是存在性复查)", () => {
|
|
374
|
+
const main = makeModel({ id: "GLM-5.3-Flash", provider: "zai-coding-cn" });
|
|
375
|
+
expect(() => resolveModel(undefined, makeTwinRegistry(), undefined, main)).toThrow(
|
|
376
|
+
/ambiguous case variants/,
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("无孪生时 ctxModel 路径零误伤(引用透传不变,P-A2 放行路径)", () => {
|
|
381
|
+
const main = makeModel({ id: "main-model", provider: "main" });
|
|
382
|
+
const r = resolveModel(undefined, makeRegistry([]), undefined, main);
|
|
383
|
+
expect(r.model).toBe(main);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("小写入参(存在 case variant)→ 首个建议为正确大写串并标注(S2 负面路径)", () => {
|
|
387
|
+
const reg = makeRegistry([makeModel({ id: "GLM-5.3-Flash", provider: "zai-coding-cn" })]);
|
|
388
|
+
let msg = "";
|
|
389
|
+
try {
|
|
390
|
+
resolveModel(undefined, reg, { model: "zai-coding-cn/glm-5.3-flash" }, ctxModel);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
msg = (e as Error).message;
|
|
393
|
+
}
|
|
394
|
+
expect(msg).toMatch(/is not a registry entry/);
|
|
395
|
+
expect(msg).toContain('zai-coding-cn/GLM-5.3-Flash ← case variant of "glm-5.3-flash"');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("放行后回显与入参全等(L1 路径 model 字段保持 registry 原始大小写)", () => {
|
|
399
|
+
const m = makeModel({ id: "GLM-5.3-Flash", provider: "zai-coding-cn" });
|
|
400
|
+
const r = resolveModel(undefined, makeRegistry([m]), { model: "zai-coding-cn/GLM-5.3-Flash" }, ctxModel);
|
|
401
|
+
expect(r.model.id).toBe("GLM-5.3-Flash");
|
|
402
|
+
expect(r.model.provider).toBe("zai-coding-cn");
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
354
406
|
// ============================================================
|
|
355
407
|
// ModelConfigService: initModel(ctxModel) → resolveModel L3 plumb-through
|
|
356
408
|
// changeset 主打路径的 runtime 契约保护(EA-4)。
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BgNotifier flushPendingNotifications —
|
|
2
|
+
* BgNotifier flushPendingNotifications — 单通道 triggerTurn 契约(U2 / D5)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 迁移史:deliverAs 从 'followUp' 改为 'steer'(FR-3/AC-3,commit d214d0d83)后,
|
|
5
|
+
* U2 courier 单通道化再收敛——steer / followUp / nextTurn 通道全部删除(nextTurn
|
|
6
|
+
* 唯一 drain 点在 session.prompt() 内,主 agent 长 streaming 场景下无限期滞留,
|
|
7
|
+
* 设计 D5 实测证伪),唯一发送形态 = sendCustomMessage({triggerTurn:true});busy
|
|
8
|
+
* 场景由 ledger(settled 边沿 + isIdle 二次复查)或内核 settled 订阅在空闲边沿驱动。
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 测试方法:mock NotifierHost,捕获 sendMessage 调用参数,断言 deliverAs === 'steer'。
|
|
10
|
+
* 测试方法:mock NotifierHost,捕获 sendMessage 调用参数,断言 options 恰为
|
|
11
|
+
* { triggerTurn: true }(G4 字节锁定测试新锚:行为契约不变,机械迁移)。
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -33,7 +34,7 @@ function makeMockHost(): NotifierHost & {
|
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
describe("BgNotifier.flushPendingNotifications —
|
|
37
|
+
describe("BgNotifier.flushPendingNotifications — 单通道 triggerTurn 契约(U2/D5)", () => {
|
|
37
38
|
let host: ReturnType<typeof makeMockHost>;
|
|
38
39
|
let notifier: BgNotifier;
|
|
39
40
|
|
|
@@ -46,7 +47,7 @@ describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
|
|
|
46
47
|
notifier.dispose();
|
|
47
48
|
});
|
|
48
49
|
|
|
49
|
-
it("flush
|
|
50
|
+
it("flush 时 sendMessage 的 options 恰为 { triggerTurn: true }(无 deliverAs,D5 单通道)", () => {
|
|
50
51
|
notifier.notify({
|
|
51
52
|
id: "bg-test-1",
|
|
52
53
|
status: "closed",
|
|
@@ -59,10 +60,10 @@ describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
|
|
|
59
60
|
// hasRunningBackground=false → notify 立即 flush
|
|
60
61
|
expect(host.sendMessageCalls).toHaveLength(1);
|
|
61
62
|
const call = host.sendMessageCalls[0];
|
|
62
|
-
expect(call.options).
|
|
63
|
+
expect(call.options).toEqual({ triggerTurn: true });
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
it("flush 时 triggerTurn
|
|
66
|
+
it("flush 时 triggerTurn 必须为 true(让父 agent 立即唤醒)", () => {
|
|
66
67
|
notifier.notify({
|
|
67
68
|
id: "bg-test-2",
|
|
68
69
|
status: "closed",
|
|
@@ -73,10 +74,7 @@ describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
|
|
|
73
74
|
});
|
|
74
75
|
|
|
75
76
|
expect(host.sendMessageCalls).toHaveLength(1);
|
|
76
|
-
expect(host.sendMessageCalls[0].options).
|
|
77
|
-
triggerTurn: true,
|
|
78
|
-
deliverAs: "steer",
|
|
79
|
-
});
|
|
77
|
+
expect(host.sendMessageCalls[0].options).toEqual({ triggerTurn: true });
|
|
80
78
|
});
|
|
81
79
|
});
|
|
82
80
|
|
|
@@ -119,12 +117,9 @@ describe("BgNotifier — isIdle gate 竞态修复", () => {
|
|
|
119
117
|
host.isIdle.mockReturnValue(true);
|
|
120
118
|
vi.advanceTimersByTime(100);
|
|
121
119
|
|
|
122
|
-
// idle
|
|
120
|
+
// idle 后发送,单通道 {triggerTurn:true}(U2/D5:无 deliverAs)
|
|
123
121
|
expect(host.sendMessageCalls).toHaveLength(1);
|
|
124
|
-
expect(host.sendMessageCalls[0].options).
|
|
125
|
-
triggerTurn: true,
|
|
126
|
-
deliverAs: "steer",
|
|
127
|
-
});
|
|
122
|
+
expect(host.sendMessageCalls[0].options).toEqual({ triggerTurn: true });
|
|
128
123
|
});
|
|
129
124
|
|
|
130
125
|
it("主 agent 持续 busy 达退避上限后强制发送(防通知饿死)", () => {
|
|
@@ -334,8 +329,8 @@ describe("BgNotifier buildLlmContent 指针行(wave2:chatMode sessionFile
|
|
|
334
329
|
|
|
335
330
|
it("[review 修复] gc-failed + patchFile 并存 → failed 文案优先(失败轮也会写 patchFile,patch 提示不可达)", () => {
|
|
336
331
|
// 回归锚定:doFinalizeRecord Step 0 对 worktreeHandle 无条件 collectPatch,gc 失败 +
|
|
337
|
-
// worktree 并存时 patchFile
|
|
338
|
-
//
|
|
332
|
+
// worktree 并存时 patchFile 有值。[U3] 判定收口到单一 deriveOutcome(cancelled →
|
|
333
|
+
// failed → patch/result),否则 LLM 被告知 completed 掩盖失败。
|
|
339
334
|
notifier.notify({
|
|
340
335
|
id: "sa-ptr-8", status: "closed", closedReason: "gc", agent: "w",
|
|
341
336
|
error: "spawn EPIPE",
|
|
@@ -347,6 +342,52 @@ describe("BgNotifier buildLlmContent 指针行(wave2:chatMode sessionFile
|
|
|
347
342
|
expect(sentContent()).toBe('Subagent "w" (sa-ptr-8) failed: spawn EPIPE');
|
|
348
343
|
});
|
|
349
344
|
|
|
345
|
+
it("[U3][D6 显式取舍] parent-shutdown 合成关闭 + patchFile 并存 → failed 文案优先(patch 提示不可达)", () => {
|
|
346
|
+
// disposeAllRecords 合成 result 恒写 error:"closed due to ..." → outcome='failed';
|
|
347
|
+
// worktree 失败并存时仍不得展示 patch 提示(「failed 优先于 patchFile 提示」保真)。
|
|
348
|
+
notifier.notify({
|
|
349
|
+
id: "sa-u3-ps", status: "closed", closedReason: "parent-shutdown", agent: "w",
|
|
350
|
+
error: "closed due to parent-shutdown",
|
|
351
|
+
patchFile: "/tmp/patches/sa-u3-ps.patch",
|
|
352
|
+
startedAt: 1, endedAt: 2,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(sentContent()).toBe('Subagent "w" (sa-u3-ps) failed: closed due to parent-shutdown');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("[U3] details payload 物化 outcome:closed 入参缺省时按 deriveOutcome 兑底填充", () => {
|
|
359
|
+
notifier.notify({
|
|
360
|
+
id: "sa-u3-mat", status: "closed", closedReason: "gc", agent: "w",
|
|
361
|
+
error: "boom",
|
|
362
|
+
startedAt: 1, endedAt: 2,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
expect(host.sendMessageCalls).toHaveLength(1);
|
|
366
|
+
const msg = host.sendMessageCalls[0]!.message as { details?: { outcome?: string } };
|
|
367
|
+
expect(msg.details?.outcome).toBe("failed");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("[U3] 显式 outcome 优先于 closedReason 兑底(一等字段直读)", () => {
|
|
371
|
+
notifier.notify({
|
|
372
|
+
id: "sa-u3-explicit", status: "closed", closedReason: "gc", agent: "w",
|
|
373
|
+
outcome: "cancelled",
|
|
374
|
+
startedAt: 1, endedAt: 2,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
expect(sentContent()).toBe('Subagent "w" (sa-u3-explicit) cancelled.');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("[U3] running(轮次通知)不物化 outcome(终态语义不适用活跃态)", () => {
|
|
381
|
+
notifier.notify({
|
|
382
|
+
id: "sa-u3-round", status: "running", agent: "w", round: 1, result: "r1",
|
|
383
|
+
startedAt: 1, endedAt: 2,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
expect(host.sendMessageCalls).toHaveLength(1);
|
|
387
|
+
const msg = host.sendMessageCalls[0]!.message as { details?: { outcome?: string } };
|
|
388
|
+
expect(msg.details?.outcome).toBeUndefined();
|
|
389
|
+
});
|
|
390
|
+
|
|
350
391
|
it("one-shot(sessionFile 未透传 → undefined)→ closed 通知与改造前逐字节一致(基线常量锚定)", () => {
|
|
351
392
|
notifier.notify({
|
|
352
393
|
id: "sa-ptr-7", status: "closed", agent: "worker", result: "done",
|
|
@@ -433,10 +474,7 @@ describe("BgNotifier — subscribeSettled 装配(must-fix #4 / D8 settled 边
|
|
|
433
474
|
settledEdges[0]!();
|
|
434
475
|
|
|
435
476
|
expect(host.sendMessageCalls).toHaveLength(1);
|
|
436
|
-
expect(host.sendMessageCalls[0]!.options).
|
|
437
|
-
triggerTurn: true,
|
|
438
|
-
deliverAs: "steer",
|
|
439
|
-
});
|
|
477
|
+
expect(host.sendMessageCalls[0]!.options).toEqual({ triggerTurn: true });
|
|
440
478
|
});
|
|
441
479
|
|
|
442
480
|
it("dispose 后 settled 边沿不再触发发送(内核 disposed 拦截)", () => {
|