@zhushanwen/pi-subagent-workflow 0.1.0 → 0.3.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/README.md +56 -0
- package/agents/context-builder.md +1 -3
- package/agents/explorer.md +27 -0
- package/agents/oracle.md +2 -2
- package/agents/orchestrator.md +48 -0
- package/agents/planner.md +1 -3
- package/agents/researcher.md +0 -2
- package/agents/reviewer.md +2 -2
- package/agents/worker.md +0 -2
- package/package.json +5 -3
- package/skills/workflow-script-format/SKILL.md +6 -6
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
- package/src/execution/__tests__/crash-recovery.test.ts +5 -1
- package/src/execution/__tests__/dialog-queue.test.ts +299 -0
- package/src/execution/__tests__/execute-nesting.test.ts +1 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +41 -9
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +59 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
- package/src/execution/__tests__/host-mode.test.ts +87 -0
- package/src/execution/__tests__/index-session-start.test.ts +342 -0
- package/src/execution/__tests__/list-component.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +78 -0
- package/src/execution/__tests__/path-encoding.test.ts +30 -1
- package/src/execution/__tests__/record-store.test.ts +86 -2
- package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
- package/src/execution/__tests__/rpc-mode.test.ts +89 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
- package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
- package/src/execution/__tests__/sdk-contract.test.ts +5 -2
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
- package/src/execution/__tests__/spawn-args.test.ts +14 -19
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
- package/src/execution/__tests__/stdin-writer.test.ts +353 -0
- package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
- package/src/execution/__tests__/tool-action.test.ts +27 -5
- package/src/execution/__tests__/ui-channels.test.ts +187 -0
- package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
- package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
- package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
- package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
- package/src/execution/__tests__/worktree-manager.test.ts +1 -1
- package/src/execution/agent-registry.ts +1 -1
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/concurrency-pool.ts +38 -6
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/execute-options-mapper.ts +21 -4
- package/src/execution/execution-record.ts +5 -0
- package/src/execution/finalize-record.ts +160 -0
- package/src/execution/get-state-handshake.ts +104 -0
- package/src/execution/host-mode.ts +52 -0
- package/src/execution/manifest-store.ts +206 -0
- package/src/execution/notifier.ts +5 -1
- package/src/execution/path-encoding.ts +18 -0
- package/src/execution/pi-invocation.ts +1 -1
- package/src/execution/record-store.ts +110 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-reconstructor.ts +11 -0
- package/src/execution/session-runner.ts +228 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/stream-sink.ts +83 -0
- package/src/execution/subagent-service.ts +230 -235
- package/src/execution/subprocess-agent-runner.ts +16 -4
- package/src/execution/types.ts +23 -3
- package/src/execution/ui-channels.ts +216 -0
- package/src/execution/ui-interaction-model.ts +48 -0
- package/src/execution/ui-request-handler-factory.ts +175 -0
- package/src/execution/ui-request-observability.ts +77 -0
- package/src/execution/ui-request-queue.ts +168 -0
- package/src/index.ts +101 -4
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
- package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
- package/src/interface/command-actions.ts +77 -0
- package/src/interface/commands.ts +40 -4
- package/src/interface/format.ts +2 -0
- package/src/interface/gui-mappers.ts +83 -0
- package/src/interface/helpers.ts +52 -9
- package/src/interface/list-component.ts +3 -1
- package/src/interface/subagent-actions.ts +44 -24
- package/src/interface/subagent-tool.ts +56 -24
- package/src/interface/subagents.ts +45 -5
- package/src/interface/tool-render.ts +16 -5
- package/src/interface/tool-workflow-script.ts +113 -15
- package/src/interface/tool-workflow.ts +92 -34
- package/src/interface/views/WorkflowsView.ts +13 -4
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
- package/src/interface/views/detail-content.ts +20 -0
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
- package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
- package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
- package/src/orchestration/agent-opts-resolver.ts +11 -2
- package/src/orchestration/error-recovery.ts +131 -23
- package/src/orchestration/execute-agent-call.ts +12 -3
- package/src/orchestration/jsonl-run-store.ts +10 -0
- package/src/orchestration/lifecycle.ts +1 -1
- package/src/orchestration/models/agent-call.ts +7 -0
- package/src/orchestration/models/ports.ts +15 -2
- package/src/orchestration/models/run-spec.ts +6 -0
- package/src/orchestration/models/trace.ts +1 -0
- package/src/orchestration/models/types.ts +19 -0
- package/src/orchestration/node-ops.ts +2 -0
- package/src/orchestration/worker-script-builder.ts +1 -0
- package/workflows/README.md +58 -0
- package/workflows/chain.js +107 -0
- package/workflows/map-reduce.js +142 -0
- package/workflows/parallel.js +131 -0
- package/workflows/scatter-gather.js +146 -0
- package/agents/scout.md +0 -17
- package/examples/README.md +0 -43
- package/examples/chain.example.js +0 -92
- package/examples/map-reduce.example.js +0 -99
- package/examples/parallel.example.js +0 -82
- package/examples/scatter-gather.example.js +0 -106
- package/src/interface/gui-adapter.ts +0 -136
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/__tests__/subagent-service-abort.test.ts
|
|
2
|
+
//
|
|
3
|
+
// S4: subagent-service abort 终态一致性测试。
|
|
4
|
+
//
|
|
5
|
+
// 审查发现:排队中被 abort(pool.acquire reject)走 finalizeFailed(status=failed),
|
|
6
|
+
// 而已运行中被 abort 走 cancelled。同一用户 abort 因时机不同产生两种终态。
|
|
7
|
+
//
|
|
8
|
+
// 修复后:pool reject 的 error 带 name="AbortError",runAndFinalize catch 判断
|
|
9
|
+
// signal?.aborted 走 cancelled 路径。
|
|
10
|
+
//
|
|
11
|
+
// 本文件覆盖两层:
|
|
12
|
+
// 1. pool 层:acquire reject 的 error.name 是 "AbortError"(S1 前半)
|
|
13
|
+
// 2. pool 层:pre-aborted signal 也带 AbortError name(S1 另一处)
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { DefaultConcurrencyPool } from "../concurrency-pool.ts";
|
|
18
|
+
|
|
19
|
+
describe("S1: ConcurrencyPool abort reject produces AbortError name", () => {
|
|
20
|
+
it("queued acquire aborted via signal rejects with error.name=AbortError", async () => {
|
|
21
|
+
const pool = new DefaultConcurrencyPool(1);
|
|
22
|
+
await pool.acquire(0); // 占满
|
|
23
|
+
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const queued = pool.acquire(0, undefined, controller.signal);
|
|
26
|
+
|
|
27
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
28
|
+
expect(pool.active).toBe(1); // 仍在排队
|
|
29
|
+
|
|
30
|
+
controller.abort();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await queued;
|
|
34
|
+
expect.unreachable("should have rejected");
|
|
35
|
+
} catch (err) {
|
|
36
|
+
expect(err).toBeInstanceOf(Error);
|
|
37
|
+
expect((err as Error).name).toBe("AbortError");
|
|
38
|
+
expect((err as Error).message).toContain("abort");
|
|
39
|
+
}
|
|
40
|
+
pool.release();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("pre-aborted signal rejects immediately with error.name=AbortError", async () => {
|
|
44
|
+
const pool = new DefaultConcurrencyPool(1);
|
|
45
|
+
await pool.acquire(0); // 占满
|
|
46
|
+
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
controller.abort(); // 先 abort 再 acquire
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await pool.acquire(0, undefined, controller.signal);
|
|
52
|
+
expect.unreachable("should have rejected");
|
|
53
|
+
} catch (err) {
|
|
54
|
+
expect(err).toBeInstanceOf(Error);
|
|
55
|
+
expect((err as Error).name).toBe("AbortError");
|
|
56
|
+
expect((err as Error).message).toContain("abort");
|
|
57
|
+
}
|
|
58
|
+
pool.release();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -18,9 +18,10 @@ import * as path from "node:path";
|
|
|
18
18
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
19
|
|
|
20
20
|
import { createRecord } from "../execution-record.ts";
|
|
21
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
21
22
|
import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
|
|
22
23
|
import type { RecordStore } from "../record-store.ts";
|
|
23
|
-
import {
|
|
24
|
+
import type { UiRequest, UiRequestHandler } from "../dialog-queue.ts";
|
|
24
25
|
import type { PiLike } from "../subagent-service.ts";
|
|
25
26
|
import { getSubagentService, setSubagentService,SubagentService } from "../subagent-service.ts";
|
|
26
27
|
import type { ExecutionRecord } from "../types.ts";
|
|
@@ -314,6 +315,75 @@ describe("SubagentService", () => {
|
|
|
314
315
|
});
|
|
315
316
|
});
|
|
316
317
|
|
|
318
|
+
// ============================================================
|
|
319
|
+
// dispose uiRequestHandler stub(dispose-cleanup Minor 优化1)
|
|
320
|
+
// ============================================================
|
|
321
|
+
//
|
|
322
|
+
// [背景] Pi 单进程 session 串行接管。session A shutdown 发 SIGTERM 后、子进程彻底
|
|
323
|
+
// close 前(graceful shutdown 窗口几十~几百 ms),子进程的 trailing extension_ui_request
|
|
324
|
+
// 仍会被父进程 pump 解析,调到 A 的 handler 闭包(仍持有 A 的 ctx)。旧实现 dispose 不清
|
|
325
|
+
// uiRequestHandler,该闭包触发 ui-request-queue.ts catch 分支打 `[subagents] uiRequestHandler
|
|
326
|
+
// threw` 误导性 console.error。dispose 现注入 stub(始终返回 {cancelled:true}),让 trailing
|
|
327
|
+
// ui_request 干净降级,不再走旧 handler 闭包。
|
|
328
|
+
//
|
|
329
|
+
// 被测点是 SubagentService.dispose 内的 setUiRequestHandler(stub)。uiRequestHandler 是
|
|
330
|
+
// private 字段,用 Reflect.get 取(与 R0-D 块访问 store 同模式)。
|
|
331
|
+
|
|
332
|
+
describe("dispose uiRequestHandler stub (dispose-cleanup)", () => {
|
|
333
|
+
/** 从 service 取出 private uiRequestHandler(trailing ui_request 实际调用入口)。 */
|
|
334
|
+
function getHandler(service: SubagentService): UiRequestHandler | undefined {
|
|
335
|
+
return Reflect.get(service, "uiRequestHandler") as UiRequestHandler | undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 最小 UiRequest(method 无关紧要——stub 不论 method 一律返回 cancelled)。 */
|
|
339
|
+
function makeTrailingRequest(id: string): UiRequest {
|
|
340
|
+
return { method: "notify", id };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
it("dispose 后 uiRequestHandler 被 stub 替换,调用返回 {cancelled:true} 不抛错", async () => {
|
|
344
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
345
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
346
|
+
|
|
347
|
+
service.dispose();
|
|
348
|
+
|
|
349
|
+
const handler = getHandler(service);
|
|
350
|
+
expect(handler).toBeDefined();
|
|
351
|
+
// 模拟 dispose 后子进程 trailing ui_request 被父进程 pump 调到 handler
|
|
352
|
+
await expect(handler!(makeTrailingRequest("trailing-1"))).resolves.toEqual({ cancelled: true });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("dispose stub 覆盖旧 handler:trailing ui_request 不产生 [subagents] console.error 噪声", async () => {
|
|
356
|
+
// 模拟 session A 的真实 handler 闭包——在 ctx 已 disposed 后调用会抛错
|
|
357
|
+
const staleHandler: UiRequestHandler = async () => {
|
|
358
|
+
throw new Error("stale ctx");
|
|
359
|
+
};
|
|
360
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
361
|
+
|
|
362
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
363
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
364
|
+
service.setUiRequestHandler(staleHandler);
|
|
365
|
+
|
|
366
|
+
service.dispose();
|
|
367
|
+
|
|
368
|
+
// dispose 后 handler 应被 stub 替换,不再是 staleHandler
|
|
369
|
+
const handler = getHandler(service);
|
|
370
|
+
expect(handler).not.toBe(staleHandler);
|
|
371
|
+
|
|
372
|
+
// trailing ui_request 调到的是 stub,不抛错、返回 cancelled
|
|
373
|
+
await expect(handler!(makeTrailingRequest("trailing-2"))).resolves.toEqual({ cancelled: true });
|
|
374
|
+
|
|
375
|
+
// 关键价值断言:无 "[subagents] uiRequestHandler threw" 误导性噪声
|
|
376
|
+
// (旧实现 dispose 不清 handler 时,trailing ui_request 走 staleHandler 抛错 →
|
|
377
|
+
// ui-request-queue.ts catch 打该日志。stub 覆盖后该路径不再触发。)
|
|
378
|
+
const subagentsErrors = errorSpy.mock.calls.filter(
|
|
379
|
+
(args: unknown[]) => typeof args[0] === "string" && args[0].includes("[subagents]"),
|
|
380
|
+
);
|
|
381
|
+
expect(subagentsErrors).toHaveLength(0);
|
|
382
|
+
|
|
383
|
+
errorSpy.mockRestore();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
317
387
|
// ============================================================
|
|
318
388
|
// execute() worktree fail-fast 校验 [MF#7](commit 8e8e75966)
|
|
319
389
|
// ============================================================
|
|
@@ -488,12 +558,12 @@ describe("SubagentService", () => {
|
|
|
488
558
|
// worktree create 抛错在 kickOffBackground 之前(execute 同步 catch),返回 background 形状
|
|
489
559
|
expect(handle.mode).toBe("background");
|
|
490
560
|
|
|
491
|
-
// register emit:background mode → id
|
|
561
|
+
// register emit:background mode → id 是 UUID 格式
|
|
492
562
|
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
493
563
|
"pending:register",
|
|
494
564
|
expect.objectContaining({
|
|
495
565
|
type: "subagent",
|
|
496
|
-
id: expect.
|
|
566
|
+
id: expect.any(String),
|
|
497
567
|
name: "general-purpose",
|
|
498
568
|
}),
|
|
499
569
|
);
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
|
|
16
16
|
import { describe, expect, it, vi } from "vitest";
|
|
17
17
|
|
|
18
|
-
import { SubprocessAgentRunner } from "../subprocess-agent-runner.ts";
|
|
19
|
-
import type { SubprocessAgentRunnerDeps } from "../subprocess-agent-runner.ts";
|
|
20
18
|
import type { AgentCallOpts, AgentResult } from "../../orchestration/models/types.ts";
|
|
19
|
+
import type { SubprocessAgentRunnerDeps } from "../subprocess-agent-runner.ts";
|
|
20
|
+
import { SubprocessAgentRunner } from "../subprocess-agent-runner.ts";
|
|
21
21
|
|
|
22
22
|
// ── 测试辅助 ──
|
|
23
23
|
|
|
@@ -42,6 +42,7 @@ function createMockService(impl?: typeof vi.fn) {
|
|
|
42
42
|
opts: Record<string, unknown>,
|
|
43
43
|
signal?: AbortSignal,
|
|
44
44
|
onEvent?: (e: Record<string, unknown>) => void,
|
|
45
|
+
stream?: unknown,
|
|
45
46
|
) => Promise<AgentResult>;
|
|
46
47
|
};
|
|
47
48
|
}
|
|
@@ -175,7 +176,37 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
|
|
|
175
176
|
const opts = { ...makeBaseOpts(), model: undefined };
|
|
176
177
|
await sar.run(opts, new AbortController().signal);
|
|
177
178
|
|
|
178
|
-
|
|
179
|
+
// 修复后:opts.model 不再从 ctxModel.id 填底,ctxModel 作为完整对象透传
|
|
180
|
+
expect(capturedOpts!.model).toBeUndefined();
|
|
181
|
+
expect(capturedOpts!.ctxModel).toBe(ctxModel);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ────────────────────────────────────────────────
|
|
186
|
+
// H1: SAR ctxModel 刷新(model_select 后不再 stale)
|
|
187
|
+
// ────────────────────────────────────────────────
|
|
188
|
+
describe("H1 ctxModel refresh via updateCtxModel", () => {
|
|
189
|
+
it("updateCtxModel 后 run() 传入新的 ctxModel 而非旧值", async () => {
|
|
190
|
+
let capturedOpts: Record<string, unknown> | undefined;
|
|
191
|
+
const mockService = createMockService(
|
|
192
|
+
vi.fn().mockImplementation((opts: Record<string, unknown>) => {
|
|
193
|
+
capturedOpts = opts;
|
|
194
|
+
return Promise.resolve(makeMockResult());
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
const oldModel = { id: "old-model", provider: "test", input: [] };
|
|
198
|
+
const newModel = { id: "new-model", provider: "test", input: [] };
|
|
199
|
+
const deps: SubprocessAgentRunnerDeps = { subagentService: mockService, ctxModel: oldModel };
|
|
200
|
+
const sar = new SubprocessAgentRunner(deps);
|
|
201
|
+
|
|
202
|
+
// 模拟 model_select:刷新 ctxModel
|
|
203
|
+
sar.updateCtxModel(newModel);
|
|
204
|
+
|
|
205
|
+
const opts = { ...makeBaseOpts(), model: undefined };
|
|
206
|
+
await sar.run(opts, new AbortController().signal);
|
|
207
|
+
|
|
208
|
+
expect(capturedOpts!.ctxModel).toBe(newModel);
|
|
209
|
+
expect(capturedOpts!.ctxModel).not.toBe(oldModel);
|
|
179
210
|
});
|
|
180
211
|
});
|
|
181
212
|
|
|
@@ -386,4 +417,42 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
|
|
|
386
417
|
expect(result.error).toBe("raw string error");
|
|
387
418
|
});
|
|
388
419
|
});
|
|
420
|
+
|
|
421
|
+
// ────────────────────────────────────────────────
|
|
422
|
+
// U1: stream 透传给 executeAndAwait
|
|
423
|
+
// ────────────────────────────────────────────────
|
|
424
|
+
describe("U1 stream 透传", () => {
|
|
425
|
+
it("SAR.run 传 stream → executeAndAwait 第 4 参收到同一 stream 对象", async () => {
|
|
426
|
+
let capturedStream: unknown;
|
|
427
|
+
const mockService = createMockService(
|
|
428
|
+
vi.fn().mockImplementation((_opts, _sig, _onEvt, stream) => {
|
|
429
|
+
capturedStream = stream;
|
|
430
|
+
return Promise.resolve(makeMockResult());
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
const deps: SubprocessAgentRunnerDeps = { subagentService: mockService };
|
|
434
|
+
const sar = new SubprocessAgentRunner(deps);
|
|
435
|
+
|
|
436
|
+
const fakeStream = { onDelta: vi.fn(), dispose: vi.fn() };
|
|
437
|
+
await sar.run(makeBaseOpts(), new AbortController().signal, undefined, fakeStream as never);
|
|
438
|
+
|
|
439
|
+
expect(capturedStream).toBe(fakeStream);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("SAR.run 不传 stream → executeAndAwait 第 4 参为 undefined", async () => {
|
|
443
|
+
let capturedStream: unknown = "sentinel";
|
|
444
|
+
const mockService = createMockService(
|
|
445
|
+
vi.fn().mockImplementation((_opts, _sig, _onEvt, stream) => {
|
|
446
|
+
capturedStream = stream;
|
|
447
|
+
return Promise.resolve(makeMockResult());
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
const deps: SubprocessAgentRunnerDeps = { subagentService: mockService };
|
|
451
|
+
const sar = new SubprocessAgentRunner(deps);
|
|
452
|
+
|
|
453
|
+
await sar.run(makeBaseOpts(), new AbortController().signal);
|
|
454
|
+
|
|
455
|
+
expect(capturedStream).toBeUndefined();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
389
458
|
});
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, it, vi } from "vitest";
|
|
7
7
|
|
|
8
|
-
import type { SubagentService } from "../subagent-service.ts";
|
|
9
8
|
import { adapter, cancelHandler, listHandler, startHandler } from "../../interface/subagent-actions.ts";
|
|
9
|
+
import type { SubagentService } from "../subagent-service.ts";
|
|
10
10
|
import type {
|
|
11
11
|
ExecutionHandle,
|
|
12
12
|
RecordSnapshot,
|
|
@@ -23,6 +23,7 @@ function makeDetails(over: Partial<SubagentToolDetails> = {}): SubagentToolDetai
|
|
|
23
23
|
agent: "worker",
|
|
24
24
|
model: "test/model",
|
|
25
25
|
thinkingLevel: undefined,
|
|
26
|
+
slug: "test-slug",
|
|
26
27
|
turns: 1,
|
|
27
28
|
totalTokens: 10,
|
|
28
29
|
elapsedSeconds: 1,
|
|
@@ -40,6 +41,7 @@ function makeSnapshot(over: Partial<RecordSnapshot> = {}): RecordSnapshot {
|
|
|
40
41
|
thinkingLevel: undefined,
|
|
41
42
|
mode: "background",
|
|
42
43
|
task: "t",
|
|
44
|
+
slug: "test-slug",
|
|
43
45
|
status: "done",
|
|
44
46
|
eventLog: [],
|
|
45
47
|
turns: 1,
|
|
@@ -74,7 +76,24 @@ describe("startHandler", () => {
|
|
|
74
76
|
|
|
75
77
|
it("task 空白 → throw", async () => {
|
|
76
78
|
const svc = makeService();
|
|
77
|
-
await expect(startHandler(svc, { task: " " }, undefined)).rejects.toThrow(/task is required/);
|
|
79
|
+
await expect(startHandler(svc, { task: " ", slug: "x" }, undefined)).rejects.toThrow(/task is required/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("slug 缺失 → throw", async () => {
|
|
83
|
+
const svc = makeService();
|
|
84
|
+
await expect(startHandler(svc, { task: "ok" }, undefined)).rejects.toThrow(/slug is required/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("slug 空白 → throw", async () => {
|
|
88
|
+
const svc = makeService();
|
|
89
|
+
await expect(startHandler(svc, { task: "ok", slug: " " }, undefined)).rejects.toThrow(/slug is required/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("slug 超 20 字符 → throw", async () => {
|
|
93
|
+
const svc = makeService();
|
|
94
|
+
await expect(
|
|
95
|
+
startHandler(svc, { task: "ok", slug: "a".repeat(21) }, undefined),
|
|
96
|
+
).rejects.toThrow(/≤20 chars/);
|
|
78
97
|
});
|
|
79
98
|
|
|
80
99
|
it("background 启动 → kind=bg + bgResponse.message 含 detached", async () => {
|
|
@@ -86,7 +105,7 @@ describe("startHandler", () => {
|
|
|
86
105
|
details: makeDetails({ status: "running", mode: "background" }),
|
|
87
106
|
})),
|
|
88
107
|
});
|
|
89
|
-
const r = await startHandler(svc, { task: "long" }, undefined);
|
|
108
|
+
const r = await startHandler(svc, { task: "long", slug: "long-running" }, undefined);
|
|
90
109
|
expect(r.kind).toBe("bg");
|
|
91
110
|
if (r.kind !== "bg") return;
|
|
92
111
|
expect(r.subagentId).toBe("bg-1-123");
|
|
@@ -297,11 +316,11 @@ describe("cancelHandler", () => {
|
|
|
297
316
|
// adapter
|
|
298
317
|
// ============================================================
|
|
299
318
|
describe("adapter", () => {
|
|
300
|
-
it("start bg → SubagentToolResult.bgResponse + content 合法 JSON(C3 回归)", () => {
|
|
319
|
+
it("start bg → SubagentToolResult.bgResponse + slug 透传 + content 合法 JSON(C3 回归)", () => {
|
|
301
320
|
const r = adapter({
|
|
302
321
|
action: "start",
|
|
303
322
|
domain: {
|
|
304
|
-
kind: "bg", subagentId: "bg-1", sessionFile: undefined,
|
|
323
|
+
kind: "bg", subagentId: "bg-1", sessionFile: undefined, slug: "extract-urls",
|
|
305
324
|
response: { status: "running", mode: "background", message: "detached, will notify on completion" },
|
|
306
325
|
},
|
|
307
326
|
});
|
|
@@ -309,9 +328,12 @@ describe("adapter", () => {
|
|
|
309
328
|
expect(r.details.subagentId).toBe("bg-1");
|
|
310
329
|
expect(r.details.bgResponse).toBeDefined();
|
|
311
330
|
expect(r.details.sessionFile).toBeNull();
|
|
331
|
+
// slug 透传到 result(renderResult 的 background 行展示用)
|
|
332
|
+
expect(r.details.slug).toBe("extract-urls");
|
|
312
333
|
// content JSON round-trip(bg 序列化回归)
|
|
313
334
|
const parsed = JSON.parse(r.content[0]!.text);
|
|
314
335
|
expect(parsed.bgResponse.message).toMatch(/detached/);
|
|
336
|
+
expect(parsed.slug).toBe("extract-urls");
|
|
315
337
|
});
|
|
316
338
|
|
|
317
339
|
it("list → 最外层 subagentId/sessionFile 为 null", () => {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// src/__tests__/ui-channels.test.ts
|
|
2
|
+
//
|
|
3
|
+
// W1 红灯测试:ui-channels.ts — channel 提取(marker 解析)+ channel 注册表。
|
|
4
|
+
//
|
|
5
|
+
// 测试对象:extensions/subagent-workflow/src/execution/ui-channels.ts(新建)
|
|
6
|
+
// 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 2:channel 注册表」
|
|
7
|
+
//
|
|
8
|
+
// parseChannel(req): ExtensionUiRequest → { channel?, channelPayload? }
|
|
9
|
+
// - select → 从 title 解析 NUL 前缀(parseFromMarkerString)
|
|
10
|
+
// - setWidget → 从 widgetLines[0] 解析 NUL 前缀(parseFromMarkerArray)
|
|
11
|
+
// - 其他 method → {}(无 channel)
|
|
12
|
+
//
|
|
13
|
+
// marker 格式:\0<UPPER_CASE_ID>[:]<JSON-payload?>
|
|
14
|
+
// - ASK_USER_MARKER = "\0XYZ_ASK_USER",payload 在 options[0]
|
|
15
|
+
// - GUI_WIDGET_MARKER = "\0XYZ_GUI_WIDGET:" + JSON,payload 在同行
|
|
16
|
+
// channel 名规范化:去 "XYZ_" 前缀,小写化(XYZ_ASK_USER → ask_user)
|
|
17
|
+
//
|
|
18
|
+
// UiChannelRegistry:
|
|
19
|
+
// - register(channel, handler) / resolve(channel) / list()
|
|
20
|
+
//
|
|
21
|
+
// 边界:无 NUL 前缀 → {};JSON parse 失败 → {}(不抛);字段缺失 → {}
|
|
22
|
+
//
|
|
23
|
+
// 红灯原因:ui-channels.ts 尚未创建,import 失败。
|
|
24
|
+
|
|
25
|
+
import { describe, expect, it, vi } from "vitest";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createUiChannelRegistry,
|
|
29
|
+
type ExtensionUiRequestLike,
|
|
30
|
+
parseChannel,
|
|
31
|
+
} from "../ui-channels.ts";
|
|
32
|
+
|
|
33
|
+
// ── 测试 fixture 构造助手 ────────────────────────────────────
|
|
34
|
+
// ExtensionUiRequestLike 是 parseChannel 入参的最小形状(method + 对应字段)。
|
|
35
|
+
|
|
36
|
+
function selectReq(title: string, options?: string[]): ExtensionUiRequestLike {
|
|
37
|
+
return { method: "select", title, options };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setWidgetReq(widgetKey: string, widgetLines: string[] | undefined): ExtensionUiRequestLike {
|
|
41
|
+
return { method: "setWidget", widgetKey, widgetLines };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── ASK_USER_MARKER 样本(真实 Pi 协议格式) ─────────────────
|
|
45
|
+
// title = "\0XYZ_ASK_USER",options[0] = JSON.stringify({questions, allowCancel})
|
|
46
|
+
const ASK_USER_MARKER = "\0XYZ_ASK_USER";
|
|
47
|
+
const askUserPayload = {
|
|
48
|
+
questions: [{ question: "Pick one", options: [{ label: "A" }, { label: "B" }] }],
|
|
49
|
+
allowCancel: true,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ── GUI_WIDGET_MARKER 样本(真实 Pi 协议格式) ───────────────
|
|
53
|
+
// widgetLines[0] = "\0XYZ_GUI_WIDGET:" + JSON.stringify({component:{...}})
|
|
54
|
+
const GUI_WIDGET_MARKER_PREFIX = "\0XYZ_GUI_WIDGET:";
|
|
55
|
+
const guiWidgetPayload = { component: { name: "StatusCard", props: { ok: true } } };
|
|
56
|
+
|
|
57
|
+
describe("parseChannel — select method(从 title 解析 NUL 前缀)", () => {
|
|
58
|
+
it("title 含 ASK_USER_MARKER → channel='ask_user' + payload 从 options[0] 解析", () => {
|
|
59
|
+
const req = selectReq(ASK_USER_MARKER, [JSON.stringify(askUserPayload)]);
|
|
60
|
+
const result = parseChannel(req);
|
|
61
|
+
expect(result.channel).toBe("ask_user");
|
|
62
|
+
expect(result.channelPayload).toEqual(askUserPayload);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("title 无 NUL 前缀 → {}(普通 select,无 channel)", () => {
|
|
66
|
+
const req = selectReq("Choose a plan", ["basic", "pro"]);
|
|
67
|
+
const result = parseChannel(req);
|
|
68
|
+
expect(result.channel).toBeUndefined();
|
|
69
|
+
expect(result.channelPayload).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("title 含 marker 但 options 缺失 → channel 解析但 payload undefined", () => {
|
|
73
|
+
// marker 在 title,但 options 为空 → channel 名仍可提取,payload 无来源
|
|
74
|
+
const req = selectReq(ASK_USER_MARKER);
|
|
75
|
+
const result = parseChannel(req);
|
|
76
|
+
expect(result.channel).toBe("ask_user");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("parseChannel — setWidget method(从 widgetLines[0] 解析 NUL 前缀)", () => {
|
|
81
|
+
it("widgetLines[0] 含 GUI_WIDGET_MARKER → channel='gui_widget' + payload", () => {
|
|
82
|
+
const req = setWidgetReq("w1", [GUI_WIDGET_MARKER_PREFIX + JSON.stringify(guiWidgetPayload)]);
|
|
83
|
+
const result = parseChannel(req);
|
|
84
|
+
expect(result.channel).toBe("gui_widget");
|
|
85
|
+
expect(result.channelPayload).toEqual(guiWidgetPayload);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("widgetLines 无 NUL 前缀 → {}(普通 setWidget)", () => {
|
|
89
|
+
const req = setWidgetReq("w1", ["plain content line"]);
|
|
90
|
+
const result = parseChannel(req);
|
|
91
|
+
expect(result.channel).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("widgetLines 为 undefined → {}", () => {
|
|
95
|
+
const req = setWidgetReq("w1", undefined);
|
|
96
|
+
const result = parseChannel(req);
|
|
97
|
+
expect(result.channel).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("widgetLines 为空数组 → {}", () => {
|
|
101
|
+
const req = setWidgetReq("w1", []);
|
|
102
|
+
const result = parseChannel(req);
|
|
103
|
+
expect(result.channel).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("parseChannel — 其他 method(无 channel 提取位置)", () => {
|
|
108
|
+
it("confirm → {}", () => {
|
|
109
|
+
const result = parseChannel({ method: "confirm", title: "t", message: "m" });
|
|
110
|
+
expect(result.channel).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("input → {}", () => {
|
|
114
|
+
const result = parseChannel({ method: "input", title: "t" });
|
|
115
|
+
expect(result.channel).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("notify → {}", () => {
|
|
119
|
+
const result = parseChannel({ method: "notify", message: "hi" });
|
|
120
|
+
expect(result.channel).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("parseChannel — channel 名规范化规则", () => {
|
|
125
|
+
it("去 XYZ_ 命名空间前缀 + 小写化(XYZ_ASK_USER → ask_user)", () => {
|
|
126
|
+
const req = selectReq("\0XYZ_ASK_USER", [JSON.stringify({ x: 1 })]);
|
|
127
|
+
expect(parseChannel(req).channel).toBe("ask_user");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("XYZ_GUI_WIDGET: 带冒号后缀 → gui_widget(去前缀 + 去冒号 + 小写)", () => {
|
|
131
|
+
const req = setWidgetReq("k", ["\0XYZ_GUI_WIDGET:" + JSON.stringify({ y: 2 })]);
|
|
132
|
+
expect(parseChannel(req).channel).toBe("gui_widget");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("parseChannel — 边界(不抛错)", () => {
|
|
137
|
+
it("marker 后 payload 非法 JSON → 不抛错,channel 仍解析(payload undefined)", () => {
|
|
138
|
+
const req = selectReq(ASK_USER_MARKER, ["not-valid-json{"]);
|
|
139
|
+
expect(() => parseChannel(req)).not.toThrow();
|
|
140
|
+
const result = parseChannel(req);
|
|
141
|
+
expect(result.channel).toBe("ask_user");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("GUI_WIDGET_MARKER 后非法 JSON → 不抛错", () => {
|
|
145
|
+
const req = setWidgetReq("k", [GUI_WIDGET_MARKER_PREFIX + "broken{json"]);
|
|
146
|
+
expect(() => parseChannel(req)).not.toThrow();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── UiChannelRegistry ────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
describe("UiChannelRegistry — 注册 + 解析 + 列举", () => {
|
|
153
|
+
it("register 后 resolve 返回注册的 handler", () => {
|
|
154
|
+
const registry = createUiChannelRegistry();
|
|
155
|
+
const handler = vi.fn();
|
|
156
|
+
registry.register("ask_user", handler);
|
|
157
|
+
expect(registry.resolve("ask_user")).toBe(handler);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("未注册的 channel resolve 返回 undefined", () => {
|
|
161
|
+
const registry = createUiChannelRegistry();
|
|
162
|
+
expect(registry.resolve("unknown")).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("list 返回所有已注册 channel 名", () => {
|
|
166
|
+
const registry = createUiChannelRegistry();
|
|
167
|
+
registry.register("ask_user", vi.fn());
|
|
168
|
+
registry.register("gui_widget", vi.fn());
|
|
169
|
+
const names = registry.list().sort();
|
|
170
|
+
expect(names).toEqual(["ask_user", "gui_widget"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("list 空注册表返回 []", () => {
|
|
174
|
+
const registry = createUiChannelRegistry();
|
|
175
|
+
expect(registry.list()).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("重复 register 同名 channel 覆盖旧 handler", () => {
|
|
179
|
+
const registry = createUiChannelRegistry();
|
|
180
|
+
const old = vi.fn();
|
|
181
|
+
const fresh = vi.fn();
|
|
182
|
+
registry.register("ask_user", old);
|
|
183
|
+
registry.register("ask_user", fresh);
|
|
184
|
+
expect(registry.resolve("ask_user")).toBe(fresh);
|
|
185
|
+
expect(registry.list()).toEqual(["ask_user"]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/__tests__/ui-interaction-model.test.ts
|
|
2
|
+
//
|
|
3
|
+
// W1 红灯测试:ui-interaction-model.ts — method 交互模型分类。
|
|
4
|
+
//
|
|
5
|
+
// 测试对象:extensions/subagent-workflow/src/execution/ui-interaction-model.ts(新建)
|
|
6
|
+
// 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 1:透传判定规则」
|
|
7
|
+
//
|
|
8
|
+
// isDialogMethod(method):
|
|
9
|
+
// - dialog(占输入焦点,等响应,需透传+排队):select / confirm / input / editor → true
|
|
10
|
+
// - fire-and-forget(纯展示/写入,不等响应):notify / setStatus / setWidget /
|
|
11
|
+
// setTitle / set_editor_text / 未知 method → false
|
|
12
|
+
//
|
|
13
|
+
// 红灯原因:ui-interaction-model.ts 尚未创建,import 失败。
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { isDialogMethod } from "../ui-interaction-model.ts";
|
|
18
|
+
|
|
19
|
+
describe("isDialogMethod — dialog 类 method(占输入焦点)", () => {
|
|
20
|
+
it("select → true(含 ask_user channel 借道 select)", () => {
|
|
21
|
+
expect(isDialogMethod("select")).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("confirm → true", () => {
|
|
25
|
+
expect(isDialogMethod("confirm")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("input → true", () => {
|
|
29
|
+
expect(isDialogMethod("input")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("editor → true", () => {
|
|
33
|
+
expect(isDialogMethod("editor")).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("isDialogMethod — fire-and-forget 类 method(纯展示/写入)", () => {
|
|
38
|
+
it("notify → false", () => {
|
|
39
|
+
expect(isDialogMethod("notify")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("setStatus → false", () => {
|
|
43
|
+
expect(isDialogMethod("setStatus")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("setWidget → false(含 gui_widget channel 借道 setWidget)", () => {
|
|
47
|
+
expect(isDialogMethod("setWidget")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("setTitle → false", () => {
|
|
51
|
+
expect(isDialogMethod("setTitle")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("set_editor_text → false", () => {
|
|
55
|
+
expect(isDialogMethod("set_editor_text")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("isDialogMethod — 未知 method 默认 fire-and-forget", () => {
|
|
60
|
+
it("未知 method 名 → false(未来 Pi 新增 method 不误判为 dialog)", () => {
|
|
61
|
+
expect(isDialogMethod("some_future_method")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("空字符串 → false", () => {
|
|
65
|
+
expect(isDialogMethod("")).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|