@zhushanwen/pi-subagent-workflow 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/agents/context-builder.md +1 -3
  2. package/agents/oracle.md +2 -2
  3. package/agents/planner.md +1 -3
  4. package/agents/researcher.md +0 -2
  5. package/agents/reviewer.md +2 -2
  6. package/agents/scout.md +13 -3
  7. package/agents/worker.md +0 -2
  8. package/package.json +5 -3
  9. package/skills/workflow-script-format/SKILL.md +6 -6
  10. package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
  11. package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
  12. package/src/execution/__tests__/execute-options-mapper.test.ts +40 -8
  13. package/src/execution/__tests__/gui-mode-dispatch.test.ts +60 -0
  14. package/src/execution/__tests__/sdk-contract.test.ts +5 -2
  15. package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
  16. package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
  17. package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
  18. package/src/execution/__tests__/tool-action.test.ts +26 -4
  19. package/src/execution/agent-result-mapper.ts +4 -1
  20. package/src/execution/concurrency-pool.ts +38 -6
  21. package/src/execution/execute-options-mapper.ts +21 -4
  22. package/src/execution/execution-record.ts +5 -0
  23. package/src/execution/record-store.ts +2 -0
  24. package/src/execution/session-reconstructor.ts +11 -0
  25. package/src/execution/session-runner.ts +12 -0
  26. package/src/execution/stream-sink.ts +83 -0
  27. package/src/execution/subagent-service.ts +68 -43
  28. package/src/execution/subprocess-agent-runner.ts +16 -4
  29. package/src/execution/types.ts +23 -3
  30. package/src/index.ts +15 -2
  31. package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
  32. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
  33. package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
  34. package/src/interface/command-actions.ts +77 -0
  35. package/src/interface/commands.ts +40 -4
  36. package/src/interface/gui-mappers.ts +83 -0
  37. package/src/interface/helpers.ts +52 -9
  38. package/src/interface/list-component.ts +3 -1
  39. package/src/interface/subagent-actions.ts +35 -22
  40. package/src/interface/subagent-tool.ts +54 -23
  41. package/src/interface/subagents.ts +45 -5
  42. package/src/interface/tool-render.ts +16 -5
  43. package/src/interface/tool-workflow-script.ts +113 -15
  44. package/src/interface/tool-workflow.ts +92 -34
  45. package/src/interface/views/WorkflowsView.ts +13 -4
  46. package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
  47. package/src/interface/views/detail-content.ts +20 -0
  48. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
  49. package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
  50. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
  51. package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
  52. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
  53. package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
  54. package/src/orchestration/agent-opts-resolver.ts +11 -2
  55. package/src/orchestration/error-recovery.ts +131 -23
  56. package/src/orchestration/execute-agent-call.ts +12 -3
  57. package/src/orchestration/jsonl-run-store.ts +10 -0
  58. package/src/orchestration/lifecycle.ts +1 -1
  59. package/src/orchestration/models/agent-call.ts +7 -0
  60. package/src/orchestration/models/ports.ts +15 -2
  61. package/src/orchestration/models/run-spec.ts +6 -0
  62. package/src/orchestration/models/trace.ts +1 -0
  63. package/src/orchestration/models/types.ts +19 -0
  64. package/src/orchestration/node-ops.ts +2 -0
  65. package/src/orchestration/worker-script-builder.ts +1 -0
  66. package/workflows/README.md +58 -0
  67. package/workflows/chain.js +107 -0
  68. package/workflows/map-reduce.js +142 -0
  69. package/workflows/parallel.js +131 -0
  70. package/workflows/scatter-gather.js +146 -0
  71. package/examples/README.md +0 -43
  72. package/examples/chain.example.js +0 -92
  73. package/examples/map-reduce.example.js +0 -99
  74. package/examples/parallel.example.js +0 -82
  75. package/examples/scatter-gather.example.js +0 -106
  76. package/src/interface/gui-adapter.ts +0 -136
@@ -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", () => {
@@ -17,6 +17,8 @@ import type { AgentResult as SubagentsAgentResult, AgentUsageTotal, ToolCall } f
17
17
  * text → content
18
18
  * parsedOutput → parsedOutput(structured-output 契约,BC-8)
19
19
  * !success && error → error(失败时填,成功时 undefined)
20
+ * !success && !error → error fallback(abort 路径:session-runner 设 success=false,error=undefined,
21
+ * 若直接透传 undefined 则 executeAgentCall 的 `error===undefined` 误判 completed。H4 修复:synthesize fallback)
20
22
  * durationMs → durationMs
21
23
  * sessionId → sessionId
22
24
  * usage (AgentUsageTotal) → usage (AgentUsage: input/output/cacheRead/cacheWrite/cost/contextTokens/turns)
@@ -31,9 +33,10 @@ export function mapToWorkflowAgentResult(
31
33
  return {
32
34
  content: r.text,
33
35
  parsedOutput: r.parsedOutput,
34
- error: r.success ? undefined : r.error,
36
+ error: r.success ? undefined : (r.error || "Agent call failed (aborted or unknown error)"),
35
37
  durationMs: r.durationMs,
36
38
  sessionId: r.sessionId,
39
+ sessionFile: r.sessionFile,
37
40
  usage: r.usage ? mapUsage(r.usage, r.turns) : undefined,
38
41
  toolCalls: r.toolCalls ? mapToolCalls(r.toolCalls) : undefined,
39
42
  };
@@ -2,17 +2,22 @@
2
2
  //
3
3
  // 并发控制 + 优先级排队。background=1000(单一优先级,保留 priority 机制供未来扩展)。
4
4
 
5
- /** 队列条目:优先级 + resolver + 入队序号(同优先级 FIFO)。 */
5
+ /** 队列条目:优先级 + resolver + rejecter + 入队序号(同优先级 FIFO)。 */
6
6
  interface QueueEntry {
7
7
  priority: number;
8
8
  resolve: () => void;
9
+ reject: (err: Error) => void;
9
10
  seq: number;
11
+ /** abort listener(用于 resolve/abort 时清理)。 */
12
+ onAbort?: () => void;
13
+ /** 关联的 signal(用于 resolve 时 removeEventListener)。 */
14
+ signal?: AbortSignal;
10
15
  }
11
16
 
12
17
  /** 并发池接口(可注入,便于测试 mock)。 */
13
18
  export interface ConcurrencyPool {
14
- /** 按优先级排队获取槽位(0=最高)。可选 effectiveMaxConcurrent 覆盖实例级默认配额。 */
15
- acquire(priority: number, effectiveMaxConcurrent?: number): Promise<void>;
19
+ /** 按优先级排队获取槽位(0=最高)。可选 effectiveMaxConcurrent 覆盖实例级默认配额。可选 AbortSignal 在 abort 时 reject 排队条目。 */
20
+ acquire(priority: number, effectiveMaxConcurrent?: number, signal?: AbortSignal): Promise<void>;
16
21
  /** 归还槽位。必须无条件执行(finally)。 */
17
22
  release(): void;
18
23
  /** 当前已占用槽位数(诊断/widget 用)。 */
@@ -45,7 +50,7 @@ export class DefaultConcurrencyPool implements ConcurrencyPool {
45
50
  this.maxConcurrent = Math.max(1, maxConcurrent);
46
51
  }
47
52
 
48
- acquire(priority: number, effectiveMaxConcurrent?: number): Promise<void> {
53
+ acquire(priority: number, effectiveMaxConcurrent?: number, signal?: AbortSignal): Promise<void> {
49
54
  // effectiveMaxConcurrent 覆盖实例级默认配额(分层配额:调用方传 max(1, maxConcurrent - depth))。
50
55
  // 不修改实例级 maxConcurrent——实例配额是全局共享上限,分层配额是本次 acquire 的局部上限。
51
56
  const effective = effectiveMaxConcurrent ?? this.maxConcurrent;
@@ -53,8 +58,31 @@ export class DefaultConcurrencyPool implements ConcurrencyPool {
53
58
  this._active += 1;
54
59
  return Promise.resolve();
55
60
  }
56
- return new Promise<void>((resolve) => {
57
- this.queue.push({ priority, resolve, seq: this.seqCounter++ });
61
+ return new Promise<void>((resolve, reject) => {
62
+ const entry: QueueEntry = { priority, resolve, reject, seq: this.seqCounter++ };
63
+ // H2: abort 时 reject 排队条目并从 queue 移除,防止永久挂起
64
+ if (signal) {
65
+ if (signal.aborted) {
66
+ // S1: abort reject 需带 name="AbortError",对齐 concurrency-gate.ts 的 AbortError 语义
67
+ const err = new Error("acquire aborted");
68
+ err.name = "AbortError";
69
+ reject(err);
70
+ return;
71
+ }
72
+ entry.signal = signal;
73
+ entry.onAbort = (): void => {
74
+ const idx = this.queue.indexOf(entry);
75
+ if (idx >= 0) {
76
+ this.queue.splice(idx, 1);
77
+ // S1: abort reject 需带 name="AbortError",与 pre-aborted 分支一致
78
+ const err = new Error("acquire aborted");
79
+ err.name = "AbortError";
80
+ reject(err);
81
+ }
82
+ };
83
+ signal.addEventListener("abort", entry.onAbort, { once: true });
84
+ }
85
+ this.queue.push(entry);
58
86
  });
59
87
  }
60
88
 
@@ -70,6 +98,10 @@ export class DefaultConcurrencyPool implements ConcurrencyPool {
70
98
  }
71
99
  }
72
100
  const next = this.queue.splice(bestIdx, 1)[0];
101
+ // H2: 条目已获槽位——移除 abort listener(防 listener 泄漏到长生命周期 signal)
102
+ if (next.onAbort && next.signal) {
103
+ next.signal.removeEventListener("abort", next.onAbort);
104
+ }
73
105
  next.resolve();
74
106
  // active 不变(一个离开队列立即进入活跃)
75
107
  } else if (this._active > 0) {
@@ -9,6 +9,12 @@ import type { AgentCallOpts } from "../orchestration/models/types.ts";
9
9
  import type { ModelInfo } from "./model-resolver.ts";
10
10
  import type { ExecuteOptions } from "./types.ts";
11
11
 
12
+ /**
13
+ * slug 最大长度(字符)。subagent/workflow 创建时 slug 超过此值会被截断。
14
+ * 与 subagent tool schema 的 maxLength: 20 保持一致。
15
+ */
16
+ export const SLUG_MAX_LENGTH = 20;
17
+
12
18
  /**
13
19
  * D-A2: AgentCallOpts → ExecuteOptions 映射。
14
20
  *
@@ -17,30 +23,41 @@ import type { ExecuteOptions } from "./types.ts";
17
23
  *
18
24
  * 映射规则:
19
25
  * prompt → task
26
+ * description → slug(≤20 字符,超长截断。缺失时回落 agent 名)
20
27
  * agent → agent
21
28
  * schema → schema
22
29
  * schemaEnv → schemaEnv(D-A6 bridge)
23
30
  * cwd → cwd
24
- * model → model ?? ctxModel(D-008 填底)
31
+ * model → model(显式 override 透传,ctxModel 不再压扁为 id 填底)
32
+ * ctxModel → ctxModel(完整 ModelInfo 对象透传,让 resolveModel 走第 3 层透明传递)
25
33
  * skillPath → skillPath
34
+ * thinkingLevel → thinkingLevel(M1: 否则下游 subagent-service 读到 undefined)
35
+ * systemPromptFiles → appendSystemPrompt(M2: resolveAgentOpts 写盘的临时文件需被 session-runner 消费)
26
36
  *
27
37
  * 忽略字段(委托后由 executeAndAwait 内部机制替代):
28
- * systemPromptFiles —— resolveIdentity 从 agentConfig.systemPrompt 读
29
38
  * timeoutMs —— mergeTimeoutSignal 单独处理
30
- * scene/description —— subagents 不消费
39
+ * scene —— subagents 不消费
40
+ *
41
+ * description 原先被忽略,现作为 slug 透传(ExecuteOptions.slug 必填)。
31
42
  */
32
43
  export function mapToExecuteOptions(
33
44
  opts: AgentCallOpts,
34
45
  ctxModel?: ModelInfo,
35
46
  ): ExecuteOptions {
47
+ // slug:优先 description,缺失回落 agent 名(保证非空)。超长截断。
48
+ const rawSlug = opts.description ?? opts.agent ?? "workflow-agent";
36
49
  return {
37
50
  task: opts.prompt,
51
+ slug: rawSlug.length > SLUG_MAX_LENGTH ? rawSlug.slice(0, SLUG_MAX_LENGTH) : rawSlug,
38
52
  agent: opts.agent,
39
53
  schema: opts.schema,
40
54
  schemaEnv: opts.schemaEnv,
41
55
  cwd: opts.cwd,
42
- model: opts.model ?? ctxModel?.id,
56
+ model: opts.model,
57
+ ctxModel,
43
58
  skillPath: opts.skillPath,
59
+ thinkingLevel: opts.thinkingLevel,
60
+ appendSystemPrompt: opts.systemPromptFiles,
44
61
  };
45
62
  }
46
63
 
@@ -147,6 +147,8 @@ export function createRecord(
147
147
  thinkingLevel?: string;
148
148
  mode: ExecutionMode;
149
149
  task: string;
150
+ /** 短标签(≤20 字符),必填。持久化兜底空串。 */
151
+ slug: string;
150
152
  startedAt: number;
151
153
  /** 根 Pi session ID(session 隔离过滤用)。递归链上所有层同值。 */
152
154
  rootSessionId?: string;
@@ -164,6 +166,7 @@ export function createRecord(
164
166
  thinkingLevel: identity.thinkingLevel,
165
167
  mode: identity.mode,
166
168
  task: identity.task,
169
+ slug: identity.slug,
167
170
  startedAt: identity.startedAt,
168
171
  rootSessionId: identity.rootSessionId,
169
172
  parentRecordId: identity.parentRecordId,
@@ -607,6 +610,7 @@ export function project(record: ExecutionRecord): SubagentToolDetails {
607
610
  agent: record.agent,
608
611
  model: record.model,
609
612
  thinkingLevel: record.thinkingLevel,
613
+ slug: record.slug,
610
614
  turns: record.turnCount,
611
615
  totalTokens: record.totalTokens,
612
616
  elapsedSeconds: computeElapsedSeconds(record),
@@ -657,6 +661,7 @@ export function snapshot(record: ExecutionRecord): RecordSnapshot {
657
661
  thinkingLevel: record.thinkingLevel,
658
662
  mode: record.mode,
659
663
  task: record.task,
664
+ slug: record.slug,
660
665
  status: record.status,
661
666
  turns: record.turnCount,
662
667
  totalTokens: record.totalTokens,
@@ -250,6 +250,7 @@ export class RecordStore {
250
250
  const rec: SubagentRecord = {
251
251
  id: recon.id,
252
252
  agent: recon.agent,
253
+ slug: recon.slug,
253
254
  status: recon.status, // 临时值,各分支覆盖
254
255
  mode: recon.mode,
255
256
  startedAt: recon.startedAt,
@@ -329,6 +330,7 @@ export class RecordStore {
329
330
  agent: r.agent,
330
331
  status: r.status,
331
332
  mode: r.mode,
333
+ slug: r.slug,
332
334
  startedAt: r.startedAt,
333
335
  rootSessionId: r.rootSessionId,
334
336
  parentRecordId: r.parentRecordId,
@@ -84,6 +84,11 @@ export interface SubagentIdentityData {
84
84
  agent: string;
85
85
  mode: ExecutionMode;
86
86
  task: string;
87
+ /**
88
+ * 短标签(≤20 字符)。旧文件缺失→读取兜底空串(见 reconstructFromFile)。
89
+ * 写入时(session-runner)必填;读取时可选以兼容旧文件。
90
+ */
91
+ slug?: string;
87
92
  startedAt: number;
88
93
  /** 根 Pi session ID(session 隔离过滤用)。旧文件可能缺失。 */
89
94
  rootSessionId?: string;
@@ -126,6 +131,8 @@ export interface ReconstructedRecord {
126
131
  agent: string;
127
132
  mode: ExecutionMode;
128
133
  task: string;
134
+ /** 短标签(≤20 字符)。旧文件缺失→兜底空串。 */
135
+ slug: string;
129
136
  startedAt: number;
130
137
  /** 根 Pi session ID(session 隔离过滤用)。旧文件可能缺失。 */
131
138
  rootSessionId: string | undefined;
@@ -353,6 +360,7 @@ export function reconstructFromFile(sessionFile: string): ReconstructedRecord |
353
360
  const turn = emptyTurn();
354
361
  turns.push(turn);
355
362
 
363
+ if (!Array.isArray(msg.content)) continue;
356
364
  for (const block of msg.content) {
357
365
  if (block.type === "text") {
358
366
  turn.text += block.text;
@@ -428,8 +436,11 @@ export function reconstructFromFile(sessionFile: string): ReconstructedRecord |
428
436
 
429
437
  // rootSessionId 归一化:新文件读 rootSessionId,旧文件 fallback parentSessionId。
430
438
  const rootSessionId = identity.rootSessionId ?? identity.parentSessionId;
439
+ // slug 兜底:旧 identity entry 无 slug 字段 → 空串(与 SubagentRecord.slug: string 契约一致)。
440
+ const slug = identity.slug ?? "";
431
441
  return {
432
442
  ...identity,
443
+ slug,
433
444
  rootSessionId,
434
445
  parentRecordId: identity.parentRecordId,
435
446
  depth: identity.depth ?? 0,
@@ -33,6 +33,7 @@ import {
33
33
  parseSpawnLine,
34
34
  type SpawnSessionHeader,
35
35
  } from "./spawn-event-adapter.ts";
36
+ import type { SubagentStream } from "./stream-sink.ts";
36
37
  import {
37
38
  cleanupTempPrompt,
38
39
  writePromptToTempFile,
@@ -181,6 +182,12 @@ export interface RunOptions {
181
182
  signal: AbortSignal | undefined;
182
183
  /** event 回流——SessionRunner 内部 updateFromEvent 后,再回调调用方(widget/notify)。 */
183
184
  onEvent: ((event: AgentEvent) => void) | undefined;
185
+ /** text_delta streaming 生命周期对象——在 text_delta 到达 onEvent 之前分流。
186
+ * background 模式下 onEvent=undefined,但 text_delta 仍可通过此对象被消费。
187
+ * 由调用方(subagent-service)创建,内部做时间窗合并后转发到 setWidget。
188
+ * workflow 路径(executeAndAwait)不传此字段——其 onEvent 是开的,
189
+ * text_delta 经 onEvent 到 workflow liveRecord,不走 streaming 通道。 */
190
+ stream?: SubagentStream;
184
191
  /** D-A6 bridge: workflow schema JSON 字符串,存在时注入 childEnv.PI_WORKFLOW_SCHEMA。
185
192
  * workflow 编排层通过 ExecuteOptions.schemaEnv 透传此处,
186
193
  * runSpawn 将其注入子进程环境变量,激活 structured-output 扩展注册 tool。
@@ -474,6 +481,10 @@ export async function runSpawn(
474
481
  const agentEvent = (event: AgentEvent): void => {
475
482
  updateFromEvent(record, event);
476
483
  if (event.type === "turn_end") limiter.onTurnEnd(record.turnCount);
484
+ // text_delta 分流到 stream 通道(在 onEvent 之前)。
485
+ // 双通道互斥设计:background 路径 stream 有值、onEvent=undefined;
486
+ // workflow 路径 onEvent 有值、stream=undefined。详见 W3 注释。
487
+ if (event.type === "text_delta") opts.stream?.onDelta(event.delta);
477
488
  opts.onEvent?.(event);
478
489
  };
479
490
 
@@ -667,6 +678,7 @@ export async function runSpawn(
667
678
  agent: record.agent,
668
679
  mode: record.mode,
669
680
  task: record.task,
681
+ slug: record.slug,
670
682
  startedAt: record.startedAt,
671
683
  rootSessionId: record.rootSessionId,
672
684
  parentRecordId: record.parentRecordId,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * subagent text_delta streaming sink。
3
+ *
4
+ * background subagent 执行期间,session-runner 的 agentEvent 出口把每个 text_delta
5
+ * 传到 SubagentStream.onDelta。本模块做 100ms 时间窗合并后,通过 StreamSink.setWidget
6
+ * 转发到 RPC stdout(经 ctx.ui.setWidget → extension_ui_request 通道)。
7
+ *
8
+ * SubagentStream 是一个生命周期对象——内聚 buffer/timer 状态 + onDelta/dispose 方法。
9
+ * 调用方(subagent-service)创建后只需在 text_delta 时调 onDelta、终态时调 dispose,
10
+ * 不需要拆散 push/clear 两个函数跨层透传。
11
+ *
12
+ * 设计要点:
13
+ * - leading edge:第一个 delta 立即 flush(前端尽快看到开始)
14
+ * - trailing edge:后续 delta 追加 buffer,timer 到期后 flush
15
+ * - 每次 flush 把 buffer 的累积全文 split("\n") 传给 setWidget
16
+ * - dispose 清除 widget + 清 timer
17
+ */
18
+
19
+ /** UI streaming sink 的最小接口(ctx.ui.setWidget 的 duck-typed 子集)。
20
+ *
21
+ * 当前只有一个 adapter(index.ts session_start 包装 ctx.ui.setWidget)。
22
+ * 保留接口而非裸函数类型,因为 StreamSink 的语义是「UI sink 契约」——
23
+ * 测试 mock 和未来可能的第二 sink(如写文件)都走此契约。 */
24
+ export interface StreamSink {
25
+ setWidget(key: string, lines: string[] | undefined): void;
26
+ }
27
+
28
+ /** delta 合并窗口时间(ms)。与 onEventThrottled 的节流间隔对齐。 */
29
+ const STREAM_FLUSH_MS = 100;
30
+
31
+ /**
32
+ * subagent text_delta streaming 生命周期对象。
33
+ *
34
+ * 创建后:
35
+ * - `onDelta(delta)`:session-runner 每次 text_delta 调
36
+ * - `dispose()`:subagent 终态时调,清除 widget + 清 timer
37
+ *
38
+ * buffer/timer 状态全部内聚在此对象,调用方不需要关心合并逻辑。
39
+ */
40
+ export class SubagentStream {
41
+ private readonly widgetKey: string;
42
+ private readonly sink: StreamSink;
43
+ private buffer = "";
44
+ private timer: ReturnType<typeof setTimeout> | undefined;
45
+ private hasFlushed = false;
46
+ private disposed = false;
47
+
48
+ constructor(recordId: string, sink: StreamSink) {
49
+ this.widgetKey = `subagent-stream-${recordId}`;
50
+ this.sink = sink;
51
+ }
52
+
53
+ /** 接收一个 text_delta 增量。空串静默丢弃(不消耗 leading edge)。 */
54
+ onDelta(delta: string): void {
55
+ if (this.disposed || delta.length === 0) return;
56
+ this.buffer += delta;
57
+ if (!this.hasFlushed) {
58
+ // leading edge:第一个 delta 立即 flush
59
+ this.hasFlushed = true;
60
+ this.flush();
61
+ } else if (this.timer === undefined) {
62
+ // trailing edge:后续 delta 经 timer 合并
63
+ this.timer = setTimeout(() => this.flush(), STREAM_FLUSH_MS);
64
+ }
65
+ }
66
+
67
+ /** 终态清理:清除 widget + 清 timer(幂等)。 */
68
+ dispose(): void {
69
+ if (this.disposed) return;
70
+ this.disposed = true;
71
+ if (this.timer !== undefined) {
72
+ clearTimeout(this.timer);
73
+ this.timer = undefined;
74
+ }
75
+ this.sink.setWidget(this.widgetKey, undefined);
76
+ }
77
+
78
+ private flush(): void {
79
+ this.timer = undefined;
80
+ if (this.buffer.length === 0 || this.disposed) return;
81
+ this.sink.setWidget(this.widgetKey, this.buffer.split("\n"));
82
+ }
83
+ }