@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.
- package/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// src/__tests__/subagent-service.test.ts
|
|
2
|
+
//
|
|
3
|
+
// SubagentService 生命周期 + 公共 API 边界测试。
|
|
4
|
+
//
|
|
5
|
+
// 范围:initSession / dispose / query / cancel / listRunning / collectRecords /
|
|
6
|
+
// onChange / assertReady -- 这些不依赖动态 import Pi SDK(getSdk)。
|
|
7
|
+
//
|
|
8
|
+
// execute() 因 buildSessionRunnerContext 会动态 import session-runner → getSdk(),
|
|
9
|
+
// 在单测环境无法提供真实 SDK,留给集成测试(见文件末尾 TODO)。
|
|
10
|
+
//
|
|
11
|
+
// 策略:用真实 ModelConfigService 指向 os.tmpdir() 空目录(loadGlobalConfig
|
|
12
|
+
// 对不存在文件返回默认配置,AgentRegistry 空目录也安全),mock PiLike。
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { createRecord } from "../execution-record.ts";
|
|
21
|
+
import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
|
|
22
|
+
import type { RecordStore } from "../record-store.ts";
|
|
23
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
24
|
+
import type { PiLike } from "../subagent-service.ts";
|
|
25
|
+
import { getSubagentService, setSubagentService,SubagentService } from "../subagent-service.ts";
|
|
26
|
+
import type { ExecutionRecord } from "../types.ts";
|
|
27
|
+
|
|
28
|
+
// ── 工具:建临时 agentDir + 真实 ModelConfigService ──
|
|
29
|
+
|
|
30
|
+
function makeTmpAgentDir(): string {
|
|
31
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "subagents-test-"));
|
|
32
|
+
// agentDir/subagents/ 子目录(config 默认路径会用,空即可)
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeModelService(agentDir: string): ModelConfigService {
|
|
37
|
+
return new ModelConfigService({ agentDir });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makePi(): PiLike & {
|
|
41
|
+
appendEntry: ReturnType<typeof vi.fn>;
|
|
42
|
+
events: { emit: ReturnType<typeof vi.fn> };
|
|
43
|
+
sendMessage: ReturnType<typeof vi.fn>;
|
|
44
|
+
} {
|
|
45
|
+
return {
|
|
46
|
+
appendEntry: vi.fn(),
|
|
47
|
+
events: { emit: vi.fn() },
|
|
48
|
+
sendMessage: vi.fn(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("SubagentService", () => {
|
|
53
|
+
let agentDir: string;
|
|
54
|
+
let modelService: ModelConfigService;
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
agentDir = makeTmpAgentDir();
|
|
58
|
+
modelService = makeModelService(agentDir);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ============================================================
|
|
66
|
+
// 构造 + 生命周期
|
|
67
|
+
// ============================================================
|
|
68
|
+
|
|
69
|
+
describe("构造 + 生命周期", () => {
|
|
70
|
+
it("构造不抛错(空 agentDir,默认 config)", () => {
|
|
71
|
+
expect(() => new SubagentService({ cwd: agentDir, modelService })).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("未 initSession 时 findRecord/cancel 抛 'pi not injected'", () => {
|
|
75
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
76
|
+
expect(() => service.findRecord("any")).toThrow(/pi not injected/);
|
|
77
|
+
expect(() => service.cancel("any")).toThrow(/pi not injected/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("initSession 后 assertReady 通过(findRecord 不再抛 pi 错,返回 undefined)", () => {
|
|
81
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
82
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
83
|
+
// findRecord 现在能过 assertReady,但 record 不存在 → 返回 undefined
|
|
84
|
+
expect(service.findRecord("missing")).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("dispose 后 findRecord 抛含 'disposed' 且带恢复指引", () => {
|
|
88
|
+
// [HISTORICAL] 旧实现只抛 "hub disposed"--无信息,调用方和 AI 盲猜。
|
|
89
|
+
// 现错误信息必须含原因 + 恢复指引,让 AI/user 知道要重启会话而非重试。
|
|
90
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
91
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
92
|
+
service.dispose();
|
|
93
|
+
expect(() => service.findRecord("any")).toThrow(/disposed/);
|
|
94
|
+
expect(() => service.findRecord("any")).toThrow(/session ended|session_start|new session/i);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("dispose 幂等(多次调用不抛)", () => {
|
|
98
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
99
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
100
|
+
expect(() => {
|
|
101
|
+
service.dispose();
|
|
102
|
+
service.dispose();
|
|
103
|
+
service.dispose();
|
|
104
|
+
}).not.toThrow();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("initSession 可 revive 已 dispose 的 service", () => {
|
|
108
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
109
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
110
|
+
service.dispose();
|
|
111
|
+
// revive
|
|
112
|
+
service.initSession({ pi: makePi(), sessionId: "s2" });
|
|
113
|
+
// 现在 assertReady 又通过(findRecord 返回 undefined 而非 disposed)
|
|
114
|
+
expect(service.findRecord("any")).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ============================================================
|
|
119
|
+
// findRecord / cancel 边界
|
|
120
|
+
// ============================================================
|
|
121
|
+
|
|
122
|
+
describe("findRecord / cancel 边界 (T4)", () => {
|
|
123
|
+
it("findRecord 不存在的 id 返回 undefined", () => {
|
|
124
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
125
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
126
|
+
expect(service.findRecord("nonexistent-id")).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("cancel 不存在的 id 返回 false(不抛错,boolean 契约不变)", () => {
|
|
130
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
131
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
132
|
+
expect(service.cancel("nonexistent-id")).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ============================================================
|
|
137
|
+
// 状态查询
|
|
138
|
+
// ============================================================
|
|
139
|
+
|
|
140
|
+
describe("状态查询", () => {
|
|
141
|
+
it("listRunning 初始为空数组", () => {
|
|
142
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
143
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
144
|
+
expect(service.listRunning()).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("collectRecords 返回数组(空 sessions 目录时为空)", () => {
|
|
148
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
149
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
150
|
+
const records = service.collectRecords(100);
|
|
151
|
+
expect(Array.isArray(records)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("onChange 返回 unsubscribe 函数,调用后停止通知", () => {
|
|
155
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
156
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
157
|
+
const listener = vi.fn();
|
|
158
|
+
const unsubscribe = service.onChange(listener);
|
|
159
|
+
expect(typeof unsubscribe).toBe("function");
|
|
160
|
+
expect(() => unsubscribe()).not.toThrow();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ============================================================
|
|
165
|
+
// resolveModel 代理
|
|
166
|
+
// ============================================================
|
|
167
|
+
|
|
168
|
+
describe("resolveModel 代理", () => {
|
|
169
|
+
it("代理到 modelService.resolveModel(未 init 时抛错)", () => {
|
|
170
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
171
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
172
|
+
// 未 init modelRegistry → resolveModel 拋错(fail-fast)
|
|
173
|
+
expect(() => service.resolveModel("worker")).toThrow(/modelRegistry not injected/);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ============================================================
|
|
178
|
+
// 进程单例访问器
|
|
179
|
+
// ============================================================
|
|
180
|
+
|
|
181
|
+
describe("进程单例访问器", () => {
|
|
182
|
+
// 保存/恢复单例,避免污染其他测试(setSubagentService 类型不接受 null)
|
|
183
|
+
const original = getSubagentService();
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
if (original) setSubagentService(original);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("setSubagentService / getSubagentService 读写一致", () => {
|
|
189
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
190
|
+
setSubagentService(service);
|
|
191
|
+
expect(getSubagentService()).toBe(service);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ============================================================
|
|
196
|
+
// dispose abort 子进程(R0-D:孤儿进程治理)
|
|
197
|
+
// ============================================================
|
|
198
|
+
//
|
|
199
|
+
// [R0] 进程退出路径:SubagentService.dispose 调 store.abortRunningControllers(),
|
|
200
|
+
// 触发所有 running background record 的 controller.abort() → runSpawn signal listener
|
|
201
|
+
// → child.kill("SIGTERM"),防止主进程退出后 background 子进程成孤儿。
|
|
202
|
+
//
|
|
203
|
+
// 被测方法在 service 层,不需要 mock spawn——直接构造 record 注册到 store。
|
|
204
|
+
// store 是 private 字段,用 Reflect.get 取(与 execute-nesting.test.ts 访问 pool 同模式)。
|
|
205
|
+
|
|
206
|
+
describe("dispose abort 子进程 (R0-D)", () => {
|
|
207
|
+
/** 从 service 取出 private store(测试注入 running record 用)。 */
|
|
208
|
+
function getStore(service: SubagentService): RecordStore {
|
|
209
|
+
return Reflect.get(service, "store") as RecordStore;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 构造一个 running background record(带 controller)并注册到 store。 */
|
|
213
|
+
function registerRunningBackground(service: SubagentService, id: string): ExecutionRecord {
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
const record = createRecord(id, {
|
|
216
|
+
agent: "general-purpose",
|
|
217
|
+
model: "test/model",
|
|
218
|
+
mode: "background",
|
|
219
|
+
task: "long task",
|
|
220
|
+
startedAt: 1_000_000,
|
|
221
|
+
rootSessionId: "s1",
|
|
222
|
+
controller,
|
|
223
|
+
});
|
|
224
|
+
// createRecord 默认 status="running";background record 持有 controller。
|
|
225
|
+
getStore(service).register(record);
|
|
226
|
+
return record;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 构造一个 running sync record(无 controller)并注册到 store。 */
|
|
230
|
+
function registerRunningSync(service: SubagentService, id: string): ExecutionRecord {
|
|
231
|
+
const record = createRecord(id, {
|
|
232
|
+
agent: "general-purpose",
|
|
233
|
+
model: "test/model",
|
|
234
|
+
mode: "sync",
|
|
235
|
+
task: "sync task",
|
|
236
|
+
startedAt: 1_000_000,
|
|
237
|
+
rootSessionId: "s1",
|
|
238
|
+
// sync 不传 controller → controller === undefined
|
|
239
|
+
});
|
|
240
|
+
getStore(service).register(record);
|
|
241
|
+
return record;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 构造一个终态 background record 并注册(用于「无 running」场景)。 */
|
|
245
|
+
function registerTerminalBackground(service: SubagentService, id: string): ExecutionRecord {
|
|
246
|
+
const controller = new AbortController();
|
|
247
|
+
const record = createRecord(id, {
|
|
248
|
+
agent: "general-purpose",
|
|
249
|
+
model: "test/model",
|
|
250
|
+
mode: "background",
|
|
251
|
+
task: "done task",
|
|
252
|
+
startedAt: 1_000_000,
|
|
253
|
+
rootSessionId: "s1",
|
|
254
|
+
controller,
|
|
255
|
+
});
|
|
256
|
+
// 直接改 status 模拟终态(不走 CAS——测试不关心状态机,只关心 dispose 的 abort 过滤)
|
|
257
|
+
record.status = "done";
|
|
258
|
+
getStore(service).register(record);
|
|
259
|
+
return record;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
it("dispose 时有 running background record → controller 被 abort", () => {
|
|
263
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
264
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
265
|
+
|
|
266
|
+
const record = registerRunningBackground(service, "bg-1");
|
|
267
|
+
|
|
268
|
+
// 前置:dispose 前 controller 未 abort
|
|
269
|
+
expect(record.controller!.signal.aborted).toBe(false);
|
|
270
|
+
|
|
271
|
+
service.dispose();
|
|
272
|
+
|
|
273
|
+
// dispose 后 controller 被 abort → runSpawn 的 signal listener 会 kill 子进程
|
|
274
|
+
expect(record.controller!.signal.aborted).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("dispose 时无 running record → 不报错,正常清理", () => {
|
|
278
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
279
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
280
|
+
|
|
281
|
+
// 全是终态 record(无 running),dispose 不应抛
|
|
282
|
+
registerTerminalBackground(service, "bg-done-1");
|
|
283
|
+
|
|
284
|
+
expect(() => service.dispose()).not.toThrow();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("dispose 已 dispose → 幂等(重复调用不抛,不重复 abort)", () => {
|
|
288
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
289
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
290
|
+
|
|
291
|
+
const record = registerRunningBackground(service, "bg-2");
|
|
292
|
+
|
|
293
|
+
service.dispose();
|
|
294
|
+
expect(record.controller!.signal.aborted).toBe(true);
|
|
295
|
+
|
|
296
|
+
// 第二次 dispose:service 已 _disposed,early-return,abortRunningControllers 不再调
|
|
297
|
+
// (即使调了也无害——已 abort 的 controller.abort() 是幂等 noop)
|
|
298
|
+
expect(() => service.dispose()).not.toThrow();
|
|
299
|
+
expect(record.controller!.signal.aborted).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("sync record(无 controller)→ dispose 跳过(不因 undefined controller 出错)", () => {
|
|
303
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
304
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
305
|
+
|
|
306
|
+
// sync record 的 controller 是 undefined,running 状态下 dispose 不应抛
|
|
307
|
+
// (abortRunningControllers 检查 r.controller 才 abort,sync 跳过)
|
|
308
|
+
// [C1] sync 子进程的 kill 由 killAllSpawnedChildren 兜底(spawnedChildren Set 注册),
|
|
309
|
+
// 集成验证见 run-spawn-integration.test.ts 的 C1 用例(mock spawn + spy kill)。
|
|
310
|
+
const syncRecord = registerRunningSync(service, "sync-1");
|
|
311
|
+
expect(syncRecord.controller).toBeUndefined();
|
|
312
|
+
|
|
313
|
+
expect(() => service.dispose()).not.toThrow();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ============================================================
|
|
318
|
+
// execute() worktree fail-fast 校验 [MF#7](commit 8e8e75966)
|
|
319
|
+
// ============================================================
|
|
320
|
+
//
|
|
321
|
+
// [MF#7] execute 入口校验 `worktree:true && !fork` → fail-fast 抛错。
|
|
322
|
+
// 否则下面三个 worktree 分支都不命中,worktreeHandle 恒 undefined → 子 agent
|
|
323
|
+
// 零文件隔离且零报错(静默 no-op)。此组验证该校验的三种 fork/worktree 组合:
|
|
324
|
+
// 1. worktree:true + fork:false → 抛 "requires fork"(fail-fast 命中)
|
|
325
|
+
// 2. worktree:true + fork:true → 不命中校验(执行越过 guard,后续因副作用失败)
|
|
326
|
+
// 3. worktree:false + fork:false → 不命中校验(默认路径,执行越过 guard)
|
|
327
|
+
//
|
|
328
|
+
// 被测点是 execute() 入口的 guard(subagent-service.ts L277-282),在任何副作用
|
|
329
|
+
// (record 创建 / worktree 创建 / spawn)之前。本文件不 mock spawn(保持与文件头
|
|
330
|
+
// 声明一致——execute 集成测试在 execute-nesting / run-spawn-integration),
|
|
331
|
+
// 因此 case 2/3 验证「guard 放行」而非「执行完成」:执行越过 guard 后在后续步骤
|
|
332
|
+
// (worktreeManager.create 调 git / runSpawn 调 spawn)抛与 fork/worktree 无关的错。
|
|
333
|
+
// 用 try/catch 断言抛出的不是 guard 错误,精确锁住 guard 的触发条件。
|
|
334
|
+
|
|
335
|
+
describe("execute() worktree fail-fast 校验 [MF#7]", () => {
|
|
336
|
+
/** 构造已就绪的 service(initSession + initModel 注入 ctxModel,使 resolveIdentity 不因 model 拗错)。 */
|
|
337
|
+
function makeReadyService(): SubagentService {
|
|
338
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
339
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
340
|
+
// 注入 modelRegistry + ctxModel:让 resolveIdentity 越过 resolveModel,
|
|
341
|
+
// 使 guard 之后的失败点稳定在 worktreeManager.create(git)或 runSpawn(spawn),
|
|
342
|
+
// 而非 modelService.resolveModel——避免与 guard 无关的 model 错误掩盖被测点。
|
|
343
|
+
modelService.initModel({
|
|
344
|
+
modelRegistry: {
|
|
345
|
+
getAvailable: () => [],
|
|
346
|
+
find: () => undefined,
|
|
347
|
+
hasConfiguredAuth: () => false,
|
|
348
|
+
},
|
|
349
|
+
sessionId: "s1",
|
|
350
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
351
|
+
});
|
|
352
|
+
return service;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
it("worktree:true + fork:false → fail-fast 抛错含 'requires fork'(guard 命中)", async () => {
|
|
356
|
+
const service = makeReadyService();
|
|
357
|
+
// guard 在所有副作用之前:无 record 创建、无 spawn
|
|
358
|
+
await expect(
|
|
359
|
+
service.execute({
|
|
360
|
+
task: "worktree without fork",
|
|
361
|
+
worktree: true,
|
|
362
|
+
fork: false,
|
|
363
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
364
|
+
}),
|
|
365
|
+
).rejects.toThrow(/requires fork/);
|
|
366
|
+
// 无副作用:record 未创建(guard 在 createRecordForMode 之前)
|
|
367
|
+
expect(service.collectRecords(10)).toHaveLength(0);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("worktree:true + fork:true → 不命中 guard(执行越过 guard,不抛 'requires fork')", async () => {
|
|
371
|
+
const service = makeReadyService();
|
|
372
|
+
// guard 放行 → 执行继续:先创建 record,然后 worktreeManager.create 调 git(测试环境无 repo → 抛与 fork 无关的错)
|
|
373
|
+
try {
|
|
374
|
+
await service.execute({
|
|
375
|
+
task: "worktree with fork",
|
|
376
|
+
worktree: true,
|
|
377
|
+
fork: true,
|
|
378
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
379
|
+
});
|
|
380
|
+
// 若未抛(理论上 worktreeManager.create 在某些环境成功)也 OK——重点是没命中 guard
|
|
381
|
+
} catch (err) {
|
|
382
|
+
// guard 放行:抛出的错误绝不能是 "requires fork"
|
|
383
|
+
expect((err as Error).message).not.toMatch(/requires fork/);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("worktree:false + fork:false → 不命中 guard(默认路径越过 guard,不抛 'requires fork')", async () => {
|
|
388
|
+
const service = makeReadyService();
|
|
389
|
+
// guard 放行 → 执行继续:runSpawn 调 child_process.spawn(测试环境无真实 pi → 抛与 fork 无关的错)
|
|
390
|
+
try {
|
|
391
|
+
await service.execute({
|
|
392
|
+
task: "default path",
|
|
393
|
+
worktree: false,
|
|
394
|
+
fork: false,
|
|
395
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
396
|
+
});
|
|
397
|
+
} catch (err) {
|
|
398
|
+
// guard 放行:抛出的错误绝不能是 "requires fork"
|
|
399
|
+
expect((err as Error).message).not.toMatch(/requires fork/);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// ============================================================
|
|
405
|
+
// pending-notifications emit 断言 (H4)
|
|
406
|
+
// ============================================================
|
|
407
|
+
//
|
|
408
|
+
// [背景] PR #82 在 subagent-service.ts 新增 emitPendingRegister/Unregister 调用,
|
|
409
|
+
// 5 个 emit 点:register(execute L309)、unregister(failed)(finalizeFailed 经
|
|
410
|
+
// finalizeRecord)、unregister(cancelled)(cancelBackground)、unregister(done)
|
|
411
|
+
//(finalizeRecord 正常完成)、worktree-fail(finalizeFailed 路径,reason=failed)。
|
|
412
|
+
// 此前本文件对这些 emit 零断言,本块补齐。
|
|
413
|
+
//
|
|
414
|
+
// [覆盖策略] 本块覆盖不依赖 runSpawn 完成的 emit 路径:
|
|
415
|
+
// - register emit:execute 内 createRecordForMode 之后立即触发,在 worktreeManager.create
|
|
416
|
+
// 之前。用 worktree:true+fork:true 让 worktreeManager.create 在测试环境(agentDir 非 git
|
|
417
|
+
// repo → git status 抛错)失败,既触发 register emit,又顺路走 finalizeFailed →
|
|
418
|
+
// unregister(failed)。(T2 Wave 0 后只有 background 模式。)
|
|
419
|
+
// - unregister(cancelled):cancelBackground 路径。手动注入 running background record 后
|
|
420
|
+
// 调公共 cancel(id) API,无需 runSpawn(覆盖场景 4)。
|
|
421
|
+
//
|
|
422
|
+
// [未覆盖路径] 需 mock spawn 才能跑完 runSpawn 的路径,本文件约定不 mock spawn
|
|
423
|
+
// (见文件头——execute 集成测试在 execute-nesting.test.ts / run-spawn-integration.test.ts):
|
|
424
|
+
// - finalizeRecord status="done"(sync/background 正常完成 → unregister(done))
|
|
425
|
+
// - finalizeRecord status="cancelled" 经 runAndFinalize 路径(cancel 抢先 CAS 时
|
|
426
|
+
// runAndFinalize 侧 tryTransition 失败跳过 finalizeRecord,由 cancelBackground 侧 emit——
|
|
427
|
+
// 本块 cancel 用例覆盖的即此后端 emit)
|
|
428
|
+
// - background detached 正常完成回注(finalizeRecord → emitPendingUnregister(done, {result,error,patchFile}))
|
|
429
|
+
// register emit 的 payload(type:"subagent"、name)由本块 worktree-fail 路径附带覆盖。
|
|
430
|
+
|
|
431
|
+
describe("pending-notifications emit 断言 (H4)", () => {
|
|
432
|
+
/** 构造已就绪 service(initSession + initModel)并保留 pi 引用以断言 events.emit。 */
|
|
433
|
+
function makeReadyServiceWithPi(): {
|
|
434
|
+
service: SubagentService;
|
|
435
|
+
pi: ReturnType<typeof makePi>;
|
|
436
|
+
} {
|
|
437
|
+
const pi = makePi();
|
|
438
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
439
|
+
service.initSession({ pi, sessionId: "s1" });
|
|
440
|
+
// 注入 modelRegistry + ctxModel:让 resolveIdentity 越过 resolveModel,
|
|
441
|
+
// 使 worktreeManager.create(git status 在非 repo 抛错)成为稳定失败点,
|
|
442
|
+
// 而非 modelService.resolveModel 抛错(那会在 record 创建之前,无法触发 register emit)。
|
|
443
|
+
modelService.initModel({
|
|
444
|
+
modelRegistry: {
|
|
445
|
+
getAvailable: () => [],
|
|
446
|
+
find: () => undefined,
|
|
447
|
+
hasConfiguredAuth: () => false,
|
|
448
|
+
},
|
|
449
|
+
sessionId: "s1",
|
|
450
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
451
|
+
});
|
|
452
|
+
return { service, pi };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 从 service 取出 private store(手动注入 running record 用,与 R0-D 块同模式)。 */
|
|
456
|
+
function getStore(service: SubagentService): RecordStore {
|
|
457
|
+
return Reflect.get(service, "store") as RecordStore;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** 手动构造 running background record(带 controller)并注册到 store(绕过 execute/spawn)。 */
|
|
461
|
+
function injectRunningBackground(service: SubagentService, id: string): ExecutionRecord {
|
|
462
|
+
const controller = new AbortController();
|
|
463
|
+
const record = createRecord(id, {
|
|
464
|
+
agent: "general-purpose",
|
|
465
|
+
model: "test/model",
|
|
466
|
+
mode: "background",
|
|
467
|
+
task: "cancel target",
|
|
468
|
+
startedAt: 1_000_000,
|
|
469
|
+
rootSessionId: "s1",
|
|
470
|
+
controller,
|
|
471
|
+
});
|
|
472
|
+
getStore(service).register(record);
|
|
473
|
+
return record;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const ctxModel: ModelInfo = { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false };
|
|
477
|
+
|
|
478
|
+
it("background execute + worktree 创建失败 → register(bg-id) + unregister(failed) 携带 error 被 emit", async () => {
|
|
479
|
+
const { service, pi } = makeReadyServiceWithPi();
|
|
480
|
+
|
|
481
|
+
const handle = await service.execute({
|
|
482
|
+
task: "wt fail bg",
|
|
483
|
+
worktree: true,
|
|
484
|
+
fork: true,
|
|
485
|
+
ctxModel,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// worktree create 抛错在 kickOffBackground 之前(execute 同步 catch),返回 background 形状
|
|
489
|
+
expect(handle.mode).toBe("background");
|
|
490
|
+
|
|
491
|
+
// register emit:background mode → id 前缀 bg-
|
|
492
|
+
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
493
|
+
"pending:register",
|
|
494
|
+
expect.objectContaining({
|
|
495
|
+
type: "subagent",
|
|
496
|
+
id: expect.stringMatching(/^bg-/),
|
|
497
|
+
name: "general-purpose",
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
// unregister(failed) 只记 registry 状态(通知由 BgNotifier 发,不在这条事件里)
|
|
501
|
+
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
502
|
+
"pending:unregister",
|
|
503
|
+
expect.objectContaining({ reason: "failed" }),
|
|
504
|
+
);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("cancel(background) → unregister(reason=cancelled) 被 emit,register 未被 emit", () => {
|
|
508
|
+
const { service, pi } = makeReadyServiceWithPi();
|
|
509
|
+
const record = injectRunningBackground(service, "bg-cancel-1");
|
|
510
|
+
expect(record.status).toBe("running");
|
|
511
|
+
|
|
512
|
+
const ok = service.cancel("bg-cancel-1");
|
|
513
|
+
|
|
514
|
+
// cancel CAS 抢锁成功 → cancelBackground 完整收尾 + emit
|
|
515
|
+
expect(ok).toBe(true);
|
|
516
|
+
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
517
|
+
"pending:unregister",
|
|
518
|
+
expect.objectContaining({ id: "bg-cancel-1", reason: "cancelled" }),
|
|
519
|
+
);
|
|
520
|
+
// record 手动注入(未走 execute)→ register 不应被 emit
|
|
521
|
+
expect(pi.events.emit).not.toHaveBeenCalledWith("pending:register", expect.anything());
|
|
522
|
+
|
|
523
|
+
service.dispose();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ── T-NFR-8 dispose emit pending:unregister(reason=failed) ──
|
|
527
|
+
//
|
|
528
|
+
// [T2 AC-4.3 双重记账一致性] 进程退出时 running record 随 detached promise 丢弃,
|
|
529
|
+
// finalizeRecord 不会再跑。dispose 中为每个 running record emit pending:unregister,
|
|
530
|
+
// 让 pending-notifications 清理 registry entry,避免两侧状态不一致。
|
|
531
|
+
// 此组验证该 emit 路径。
|
|
532
|
+
|
|
533
|
+
it("T-NFR-8: dispose 时每个 running record 都 emit pending:unregister(reason=failed)", () => {
|
|
534
|
+
const { service, pi } = makeReadyServiceWithPi();
|
|
535
|
+
injectRunningBackground(service, "bg-dispose-1");
|
|
536
|
+
injectRunningBackground(service, "bg-dispose-2");
|
|
537
|
+
injectRunningBackground(service, "bg-dispose-3");
|
|
538
|
+
|
|
539
|
+
service.dispose();
|
|
540
|
+
|
|
541
|
+
// 每个 running record 都 emit 了 pending:unregister(reason=failed)
|
|
542
|
+
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
543
|
+
"pending:unregister",
|
|
544
|
+
expect.objectContaining({ id: "bg-dispose-1", reason: "failed" }),
|
|
545
|
+
);
|
|
546
|
+
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
547
|
+
"pending:unregister",
|
|
548
|
+
expect.objectContaining({ id: "bg-dispose-2", reason: "failed" }),
|
|
549
|
+
);
|
|
550
|
+
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
551
|
+
"pending:unregister",
|
|
552
|
+
expect.objectContaining({ id: "bg-dispose-3", reason: "failed" }),
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("T-NFR-8: dispose emit 次数 = running record 数(终态 record 不重复 emit)", () => {
|
|
557
|
+
const { service, pi } = makeReadyServiceWithPi();
|
|
558
|
+
// 2 个 running + 1 个终态
|
|
559
|
+
injectRunningBackground(service, "bg-running-1");
|
|
560
|
+
injectRunningBackground(service, "bg-running-2");
|
|
561
|
+
const terminal = injectRunningBackground(service, "bg-done");
|
|
562
|
+
terminal.status = "done"; // 模拟终态,dispose 不应为其 emit
|
|
563
|
+
|
|
564
|
+
service.dispose();
|
|
565
|
+
|
|
566
|
+
// 统计 pending:unregister 调用次数
|
|
567
|
+
const unregisterCalls = pi.events.emit.mock.calls.filter(
|
|
568
|
+
(call: unknown[]) => call[0] === "pending:unregister",
|
|
569
|
+
);
|
|
570
|
+
// 只有 2 个 running record emit,终态的 bg-done 不 emit
|
|
571
|
+
expect(unregisterCalls).toHaveLength(2);
|
|
572
|
+
// 确认终态 record 未被 emit
|
|
573
|
+
expect(pi.events.emit).not.toHaveBeenCalledWith(
|
|
574
|
+
"pending:unregister",
|
|
575
|
+
expect.objectContaining({ id: "bg-done" }),
|
|
576
|
+
);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// ============================================================
|
|
582
|
+
// ModelConfigService ctxModel 缓存(renderCall 标题行 model 显示的核心)
|
|
583
|
+
// ============================================================
|
|
584
|
+
//
|
|
585
|
+
// [HISTORICAL] 99f20da1e 后 renderCall 拿不到主 agent model(ToolRenderContext 无 model),
|
|
586
|
+
// resolveModel 第三层拗错→降级不显示 model。修复:session_start 缓存 ctxModel,
|
|
587
|
+
// resolveModel 第三参默认用缓存。此测试验证该透传链路。
|
|
588
|
+
|
|
589
|
+
describe("ModelConfigService ctxModel 缓存", () => {
|
|
590
|
+
let agentDir: string;
|
|
591
|
+
|
|
592
|
+
beforeEach(() => {
|
|
593
|
+
agentDir = makeTmpAgentDir();
|
|
594
|
+
});
|
|
595
|
+
afterEach(() => {
|
|
596
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
/** 最小 mock registry:空可用列表(ctxModel 路径不需要 lookup)。 */
|
|
600
|
+
function makeEmptyRegistry(): ModelRegistryLike {
|
|
601
|
+
return {
|
|
602
|
+
getAvailable: () => [],
|
|
603
|
+
find: () => undefined,
|
|
604
|
+
hasConfiguredAuth: () => false,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
it("initModel 传 ctxModel 后,resolveModel 不传第三参返回缓存 model", () => {
|
|
609
|
+
const svc = makeModelService(agentDir);
|
|
610
|
+
const mainModel: ModelInfo = {
|
|
611
|
+
id: "main-model",
|
|
612
|
+
name: "Main",
|
|
613
|
+
provider: "anthropic",
|
|
614
|
+
reasoning: false,
|
|
615
|
+
};
|
|
616
|
+
svc.initModel({
|
|
617
|
+
modelRegistry: makeEmptyRegistry(),
|
|
618
|
+
sessionId: "sess-1",
|
|
619
|
+
ctxModel: mainModel,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// agent 无 model 声明 + 无 override → 走第三层 ctxModel 缓存
|
|
623
|
+
const r = svc.resolveModel("general-purpose");
|
|
624
|
+
expect(r.model).toBe(mainModel);
|
|
625
|
+
expect(r.model.provider).toBe("anthropic");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("显式 ctxModel 参数优先于缓存(execute 路径覆盖 renderCall 缓存)", () => {
|
|
629
|
+
const svc = makeModelService(agentDir);
|
|
630
|
+
const cached: ModelInfo = { id: "cached", name: "C", provider: "p1", reasoning: false };
|
|
631
|
+
const explicit: ModelInfo = { id: "explicit", name: "E", provider: "p2", reasoning: false };
|
|
632
|
+
svc.initModel({
|
|
633
|
+
modelRegistry: makeEmptyRegistry(),
|
|
634
|
+
sessionId: "sess-1",
|
|
635
|
+
ctxModel: cached,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// execute 传显式 ctxModel → 用显式,不用缓存
|
|
639
|
+
const r = svc.resolveModel("general-purpose", undefined, explicit);
|
|
640
|
+
expect(r.model).toBe(explicit);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("setCtxModel 刷新缓存(model_select 后 renderCall 能看到新 model)", () => {
|
|
644
|
+
const svc = makeModelService(agentDir);
|
|
645
|
+
const m1: ModelInfo = { id: "m1", name: "1", provider: "p", reasoning: false };
|
|
646
|
+
const m2: ModelInfo = { id: "m2", name: "2", provider: "p", reasoning: false };
|
|
647
|
+
svc.initModel({ modelRegistry: makeEmptyRegistry(), sessionId: "s", ctxModel: m1 });
|
|
648
|
+
|
|
649
|
+
expect(svc.resolveModel("general-purpose").model).toBe(m1);
|
|
650
|
+
svc.setCtxModel(m2); // 模拟 model_select 刷新
|
|
651
|
+
expect(svc.resolveModel("general-purpose").model).toBe(m2);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("缓存为空且无 override/agentConfig.model → 拗错(不静默降级)", () => {
|
|
655
|
+
const svc = makeModelService(agentDir);
|
|
656
|
+
svc.initModel({
|
|
657
|
+
modelRegistry: makeEmptyRegistry(),
|
|
658
|
+
sessionId: "s",
|
|
659
|
+
// ctxModel 不传 → 缓存为空
|
|
660
|
+
});
|
|
661
|
+
// 空 registry + 空 ctxModel → 第三层不可用 → 拗错
|
|
662
|
+
expect(() => svc.resolveModel("general-purpose")).toThrow(/No available model/);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// ============================================================
|
|
667
|
+
// execute() 集成测试 — 已由 execute-integration.test.ts 覆盖
|
|
668
|
+
// ============================================================
|
|
669
|
+
// 原先此处的 TODO 已落地为 src/__tests__/execute-integration.test.ts(12 用例),
|
|
670
|
+
// 通过 mock 最底层的 SDK 边界(session-runner.getSdk → fakeSdk)跑通完整编排链路:
|
|
671
|
+
// - sync happy / sync error / createAgentSession 失败(finalizeFailed)
|
|
672
|
+
// - background 启动 / background cancel CAS(running 成功 + 已终态 false)
|
|
673
|
+
// - dispose flush(sliding window 内 pending notification)
|
|
674
|
+
// - run() 事件累积(turn_end / message_end usage / tool_start+end / error stopReason)
|
|
675
|
+
// - sync signal abort → cancelled
|
|
676
|
+
// - schema enforcement steer(漏调 structured-output)
|
|
677
|
+
// - onUpdate 回流(TRIGGERING_EVENT_TYPES)
|
|
678
|
+
// 同时覆盖 session-runner.run() —— event-bridge 合并进 run() 后的事件处理回归。
|