@zhushanwen/pi-subagent-workflow 9.0.0 → 9.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.
Files changed (52) hide show
  1. package/package.json +10 -9
  2. package/skills/subagent-ext-config/SKILL.md +9 -11
  3. package/src/host/__tests__/pi-host.test.ts +0 -1
  4. package/src/index.ts +7 -7
  5. package/src/injectors/subagent-list-injector.ts +1 -1
  6. package/src/interface/command/__tests__/commands-open.test.ts +278 -0
  7. package/src/interface/{commands.ts → command/commands.ts} +15 -7
  8. package/src/interface/{subagents.ts → command/subagents.ts} +4 -4
  9. package/src/interface/format/__tests__/display-state.test.ts +146 -0
  10. package/src/interface/format/display-state.ts +112 -0
  11. package/src/interface/{format.ts → format/format.ts} +20 -14
  12. package/src/interface/{id-preview.ts → format/id-preview.ts} +1 -1
  13. package/src/interface/{bg-notify-render.ts → gui/bg-notify-render.ts} +60 -35
  14. package/src/interface/gui/gui-mappers.ts +74 -0
  15. package/src/interface/{subagent-actions.ts → gui/subagent-actions.ts} +3 -3
  16. package/src/interface/{__tests__ → tool/__tests__}/capture-tool.ts +1 -1
  17. package/src/interface/tool/__tests__/subagent-tool-actions.test.ts +238 -0
  18. package/src/interface/{__tests__ → tool/__tests__}/subagent-tool-path-guard.test.ts +6 -5
  19. package/src/interface/{__tests__ → tool/__tests__}/tool-subagents.test.ts +4 -4
  20. package/src/interface/tool/__tests__/tool-workflow-resume.test.ts +161 -0
  21. package/src/interface/{__tests__ → tool/__tests__}/tool-workflow.test.ts +33 -9
  22. package/src/interface/{subagent-tool-schema.ts → tool/subagent-tool-schema.ts} +1 -1
  23. package/src/interface/{subagent-tool.ts → tool/subagent-tool.ts} +4 -4
  24. package/src/interface/{tool-render.ts → tool/tool-render.ts} +3 -3
  25. package/src/interface/{tool-shared.ts → tool/tool-shared.ts} +1 -1
  26. package/src/interface/{tool-subagents.ts → tool/tool-subagents.ts} +12 -11
  27. package/src/interface/{tool-workflow-script.ts → tool/tool-workflow-script.ts} +1 -1
  28. package/src/interface/{tool-workflow.ts → tool/tool-workflow.ts} +20 -165
  29. package/src/interface/tui/__tests__/list-view.test.ts +188 -0
  30. package/src/interface/{list-component.ts → tui/list-component.ts} +2 -2
  31. package/src/interface/{list-shared.ts → tui/list-shared.ts} +1 -1
  32. package/src/interface/{list-view.ts → tui/list-view.ts} +2 -2
  33. package/src/interface/{tui-kit.ts → tui/tui-kit.ts} +1 -1
  34. package/src/interface/{views → tui/views}/WorkflowsView.ts +10 -6
  35. package/src/interface/tui/views/__tests__/WorkflowsView.driver.test.ts +595 -0
  36. package/src/interface/{views → tui/views}/__tests__/WorkflowsView.test.ts +10 -3
  37. package/src/interface/tui/views/__tests__/detail-content.test.ts +195 -0
  38. package/src/interface/{views → tui/views}/detail-content.ts +8 -3
  39. package/src/interface/{views → tui/views}/view-constants.ts +2 -2
  40. package/src/jsonl-run-store.ts +133 -81
  41. package/src/session-lifecycle.ts +15 -42
  42. package/src/workflow-events.ts +21 -3
  43. package/src/workflow-notify.ts +8 -7
  44. package/src/interface/__tests__/tool-workflow-resume.test.ts +0 -227
  45. package/src/interface/gui-mappers.ts +0 -81
  46. /package/src/interface/{__tests__ → command/__tests__}/commands-resume.test.ts +0 -0
  47. /package/src/interface/{command-actions.ts → command/command-actions.ts} +0 -0
  48. /package/src/interface/{__tests__ → tool/__tests__}/tool-prompt-contract.test.ts +0 -0
  49. /package/src/interface/{__tests__ → tool/__tests__}/tool-render.test.ts +0 -0
  50. /package/src/interface/{__tests__ → tool/__tests__}/tool-workflow-script.test.ts +0 -0
  51. /package/src/interface/{reentry-guard.ts → tool/reentry-guard.ts} +0 -0
  52. /package/src/interface/{tool-result.ts → tool/tool-result.ts} +0 -0
@@ -21,7 +21,10 @@ import * as path from "node:path";
21
21
  import type { ExtensionAPI, ExtensionContext, SessionStartEvent } from "@earendil-works/pi-coding-agent";
22
22
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
23
23
  import { getLogger } from "@zhushanwen/pi-extension-logger";
24
- import { guardStaleCtx, oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
24
+ import { oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
25
+ // notify ledger host 装配工厂(五端口接线 + 送达 stale 防御的共享单点实现,
26
+ // 与 session-manager notify-ledger.ts 同一装配机制收敛于此)
27
+ import { createPiNotifyLedgerHost } from "@zhushanwen/pi-notify-ledger-host";
25
28
  // [W2/V4 D6] 恢复链注销直落消费的 protocol SSOT(customType + status 映射单点——
26
29
  // 与 finalizeRun 直落 / reconcile-sweep 补注销同一函数,零新增定义点)。
27
30
  import { mapReasonToStatus, PENDING_UNREGISTER_ENTRY_TYPE } from "@zhushanwen/extension-protocol";
@@ -72,6 +75,7 @@ import type { WorkflowRun } from "@zhushanwen/subagent-core";
72
75
  // 「导入面折叠直连」语义)
73
76
  import { normalizeEngineId } from "@zhushanwen/subagent-core";
74
77
  import { JsonlRunStore } from "./jsonl-run-store.ts";
78
+ import { GLOBAL_SLOT_KEYS } from "@zhushanwen/subagent-core";
75
79
 
76
80
  // 模块级 logger(与原 index.ts 同 component 名;setPiHandle 注入后自动走 appendEntry)
77
81
  const logger = getLogger("subagents");
@@ -285,7 +289,7 @@ async function collectAliveWorkflowRunReferences(
285
289
  // dialog queue 仍为本模块私有单例——消费方 = 本文件 createOrReuseServices +
286
290
  // index.ts session_shutdown(flush L2 pending dialog)。
287
291
 
288
- const DIALOG_QUEUE_KEY = Symbol.for("@zhushanwen/pi-subagents.dialogQueue");
292
+ const DIALOG_QUEUE_KEY = Symbol.for(GLOBAL_SLOT_KEYS.dialogQueue);
289
293
 
290
294
  /** 获取或创建进程级 dialog queue 单例。
291
295
  * L2 跨子进程串行队列——所有子进程的 dialog 类请求共享同一队列实例。 */
@@ -519,46 +523,15 @@ function appendSubagentIdentityEntry(pi: ExtensionAPI): void {
519
523
  * 返回 undefined。
520
524
  */
521
525
  export function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext): NotifyLedgerHost | undefined {
522
- const ledgerHost: NotifyLedgerHost = {
523
- appendLedgerEntry: (customType, data) => {
524
- pi.appendEntry(customType, data);
525
- },
526
- readSessionEntries: () => ctx.sessionManager.getEntries(),
527
- isIdle: () => ctx.isIdle(),
528
- onAgentSettled: (handler) => {
529
- pi.on("agent_settled", handler);
530
- },
531
- sendDelivery: (message) => {
532
- // D5 单通道:唯一发送形态 = sendCustomMessage({triggerTurn:true}),
533
- // courier 已在发送前二次复查 isIdle,多通道投递选项已删(D5)。
534
- // stale ctx 防御(crash-resilience D1 / ext-guards 审计 §7 blockers#1 收口):
535
- // sendDelivery 经 settled 边沿 / 看门狗 / 恢复重放异步触发——session 替换窗口
536
- // 触碰 stale pi 命中 assertActive(PS-30)即无人接 rejection(E1 同机制)。
537
- // stale 静默降级(本条通知不投递,attemptDeliver 按已受理标 sentAt——session
538
- // 替换后通知对旧 session 已无意义,与守卫前「留 pending 反复撞 stale 直到账本
539
- // 重绑」终局一致),非 stale 错误原样上抛(attemptDeliver 既有 catch 走
540
- // settleRejected 留账重试语义不变)。
541
- guardStaleCtx(() => pi.sendMessage(message, { triggerTurn: true }), {
542
- label: "subagent-workflow:sendDelivery",
543
- onStale: (error) =>
544
- logger.warn("notify delivery skipped (stale ctx)", {
545
- error: toErrorMessage(error),
546
- }),
547
- });
548
- },
549
- // abandon 对会话补显形(T4③ 放弃终态):不唤醒的 display 消息(无 triggerTurn),
550
- // 让主 agent/用户在会话里看到「通知已放弃」线索。
551
- // 与 sendDelivery(triggerTurn 唤醒)分工,通道不复用。
552
- sendDisplayMessage: (message) => {
553
- guardStaleCtx(() => pi.sendMessage(message), {
554
- label: "subagent-workflow:sendDisplayMessage",
555
- onStale: (error) =>
556
- logger.warn("notify abandon display skipped (stale ctx)", {
557
- error: toErrorMessage(error),
558
- }),
559
- });
560
- },
561
- };
526
+ // host 五端口接线 + 送达 stale 防御(D5 单通道 {triggerTurn:true} / stale 静默降级 /
527
+ // abandon 补显形 T4③)收敛在 @zhushanwen/pi-notify-ledger-host 工厂,权威注释随迁
528
+ // 工厂内;component/logger 参数保持本包 label 前缀与 warn 通道归因不变,
529
+ // sendDisplayMessage = 生产 bind 恒实现(NotifyLedgerHost 可选端口的本侧既有语义)。
530
+ const ledgerHost = createPiNotifyLedgerHost(pi, ctx, {
531
+ component: "subagent-workflow",
532
+ logger,
533
+ sendDisplayMessage: true,
534
+ });
562
535
  // bind 与 recover 拆独立 try(失败归因不同):
563
536
  // - bind 失败:槽上无 ledger,消费方(getBoundNotifyLedger)退回内核直发路径;
564
537
  // - recover 失败:bind 已成功、槽上 ledger 仍在,消费方照常走账本路径(重启重放
@@ -66,7 +66,7 @@ import { WorkflowScriptRegistryImpl } from "@zhushanwen/subagent-core";
66
66
  // 管道),session_start / session_shutdown 驱动 attach/detach;测试可注入 fake。
67
67
  import { createInFlightReporter, type InFlightReporter } from "./host/inflight-reporter.ts";
68
68
  // [W2/V1 D1 第 8 行] 判活类消费面(isScriptRunning)经 core 投影单点
69
- //(runSummary.status 二值——内部收拢注册表 ∨ 聚合混合判源)。
69
+ //(runSummary.status 三态投影——终局判定源 = 终局记录注册表)。
70
70
  import { type RunSettlementRecord } from "./jsonl-run-store.ts";
71
71
  import { notifyDone, trackNotifiedRunId } from "./workflow-notify.ts";
72
72
  // ═══ 跨域事件注册(handler 体住各自域模块,本 seam 原位调用保注册顺序) ═══
@@ -74,6 +74,7 @@ import { setupNotifyLedgerCompactionGuard } from "./workflow-notify.ts";
74
74
  import { setupModelEvents } from "./model-events.ts";
75
75
  import { setupSubagentsCascadeEvents } from "./subagents-events.ts";
76
76
  // ═══ session 生命周期装配 seam(bootstrap seam,设计 §3.1/D1) ═══
77
+ import { GLOBAL_SLOT_KEYS } from "@zhushanwen/subagent-core";
77
78
  import {
78
79
  getOrCreateDialogQueue,
79
80
  setupSessionLifecycle,
@@ -125,7 +126,7 @@ function createWorkflowDomainState(): WorkflowDomainState {
125
126
  // 此槽拿回同一 domain state,session_shutdown(reload) 跳过清理(D1)保住的
126
127
  // 在飞 run / store 由 post-reload session_start(reason=reload) 的 adoption
127
128
  // 接管(D4,session-lifecycle.ts)。
128
- const WORKFLOW_DOMAIN_SLOT_KEY = Symbol.for("@zhushanwen/pi-subagents.workflow-domain-state");
129
+ const WORKFLOW_DOMAIN_SLOT_KEY = Symbol.for(GLOBAL_SLOT_KEYS.workflowDomainState);
129
130
 
130
131
  function getOrCreateWorkflowDomainState(): WorkflowDomainState {
131
132
  let state = Reflect.get(globalThis, WORKFLOW_DOMAIN_SLOT_KEY) as WorkflowDomainState | undefined;
@@ -148,7 +149,7 @@ function resolveCurrentPi(): ExtensionAPI {
148
149
  if (!pi) {
149
150
  throw new Error(
150
151
  "workflow deps current pi binding unset: setupWorkflowDomain has not run " +
151
- "(slot @zhushanwen/pi-subagents.workflow-domain-state); re-run extension factory to re-register",
152
+ `(slot ${GLOBAL_SLOT_KEYS.workflowDomainState}); re-run extension factory to re-register`,
152
153
  );
153
154
  }
154
155
  return pi;
@@ -482,6 +483,15 @@ export function setupWorkflowDomain(
482
483
  const sessionId = ctx.sessionManager.getSessionId();
483
484
  lsRef.lastSessionId = sessionId;
484
485
 
486
+ // [PS-68] tree 导航 = 同进程分支导航,非会话替换:pi 原地换叶子
487
+ //(agent.state.messages = sessionContext.messages)后 emit,不 teardown、不失效
488
+ // runner——pi 绑定保持有效,此处不作废。替换形态 = new/fork/resume/quit +
489
+ // switchSession(借 reason="resume",无专用枚举成员),全部发 session_shutdown;
490
+ // switchSession 的替换语义由 session_shutdown 非 reload 分支承接,与本 handler
491
+ // 无交集。reload 另有本文件 :546 显式作废。禁止在此 invalidatePiBinding:作废后
492
+ // 唯一重臂点 initSession 只由 session_start 触发,tree 导航不触发 session_start,
493
+ // 本 session 余生 record/notify 的 pi 写入将静默 no-op。
494
+ // [HISTORICAL] 曾按「switchSession 不发 session_shutdown、其内部路径即 tree 导航域」的假前提在此作废绑定,撤销依据与触发词表登记见 docs/pi-semantics.json PS-61。
485
495
  const state = sessionState.get(sessionId);
486
496
  if (state) {
487
497
  // 一次性生命周期(D-2):running run 转 done,failed 落盘(helper 内部自过滤
@@ -526,6 +536,14 @@ export function setupWorkflowDomain(
526
536
  // ════════════════════════════════════════════════════════════
527
537
  pi.on("session_shutdown", async (event: SessionShutdownEvent, _ctx: ExtensionContext) => {
528
538
  if (event.reason === "reload") {
539
+ // [§1.4 (a) reload 分支显式作废句柄] reload 有意跳过全部破坏性清理(下方 D1 分支
540
+ // 说明——在途 run 交给 reload 后 adoption 接管),但 pi 会话替换会使旧 runner
541
+ // 失效(PS-30:assertActive 抛 stale after session replacement),core SubagentService
542
+ // 的 late-bound pi 读面在旧失效句柄上会一路命中 assertActive 抛错(在途 run 的
543
+ // 轮终收尾正落该窗口)。此处只作废「句柄可用性判定」,不动在途 run 纳管:
544
+ // 作废后读面降级 null(轮终收尾经有界等待挂到新 initSession 注入),新 session_start
545
+ // 注入新代际句柄后自然恢复。方法本身不抛(字段写 + 唤醒等待者)。
546
+ getSubagentService()?.invalidatePiBinding("session replacement (reload)");
529
547
  // b 动作保留(理由见上方 D1 分支说明)。
530
548
  inflightReporter.detachSession();
531
549
 
@@ -35,7 +35,7 @@ import {
35
35
  boundedPrettySerialize,
36
36
  getBoundNotifyLedger,
37
37
  isTerminalDoneReason,
38
- RUN_EVENT_JOURNAL_SUFFIX,
38
+ RUN_EVENTS_SUFFIX,
39
39
  type RunOutcome,
40
40
  } from "@zhushanwen/subagent-core";
41
41
  import type { WorkflowRun } from "@zhushanwen/subagent-core";
@@ -58,12 +58,13 @@ const MAX_RESULT_SUMMARY_LENGTH = 500;
58
58
  const RECORD_FAIL_CONTENT_PREVIEW_LENGTH = 200;
59
59
 
60
60
  /**
61
- * [D7] 事件 journal 文件名(run store 旁,artifactsDir 内):`<runId>.events.jsonl`
62
- * (runId 即 generateRunId 的 wf- 前缀产物,渲染名 = 设计 D5 的 wf-<id>.events.jsonl;
63
- * 后缀经 core barrel 单源 RUN_EVENT_JOURNAL_SUFFIX,core 命名变更时编译期同步)。
61
+ * [D7] 事件 journal 文件名(run store 旁,artifactsDir 内):`<runId>.record.jsonl`
62
+ * (runId 即 generateRunId 的 wf- 前缀产物;后缀随 core RUN_EVENTS_SUFFIX 改名,
63
+ * 历史 .events.jsonl = 旧写入方实体,设计 D5 的渲染名 wf-<id>.events.jsonl 即该
64
+ * 旧形态;实值经 core barrel 单源 RUN_EVENTS_SUFFIX,core 命名变更时编译期同步)。
64
65
  */
65
66
  function runEventsJournalPath(artifactsDir: string, runId: string): string {
66
- return join(artifactsDir, `${runId}${RUN_EVENT_JOURNAL_SUFFIX}`);
67
+ return join(artifactsDir, `${runId}${RUN_EVENTS_SUFFIX}`);
67
68
  }
68
69
 
69
70
  /**
@@ -103,7 +104,7 @@ export const MAX_NOTIFIED_RUN_IDS = 1000;
103
104
  * 模块私有(原 export 已删):无跨文件消费者,测试经 notifyDone 公共入口
104
105
  * 观察并内联类型。
105
106
  */
106
- interface WorkflowNotifyDetails {
107
+ interface WorkflowNotifyDetails { // oe-exempt:20260929:framework:workflow/record 协议契约类型——ports 类型契约先行、单实现常态(dev-0.10.5 已验收代码 merge 带入)
107
108
  runId: string;
108
109
  name: string;
109
110
  status: string;
@@ -138,7 +139,7 @@ interface WorkflowNotifyDetails {
138
139
  */
139
140
  artifactsDir?: string;
140
141
  /**
141
- * [D7] 事件 journal 指针(`<artifactsDir>/wf-<runId>.events.jsonl`)——终局
142
+ * [D7] 事件 journal 指针(`<artifactsDir>/wf-<runId>.record.jsonl`)——终局
142
143
  * 证据的入口载荷(D5-3 诊断引用落账的消费面)。
143
144
  */
144
145
  eventsJournalPath?: string;
@@ -1,227 +0,0 @@
1
- /**
2
- * workflow tool resume action 契约(workflow-run-resume-revision U3 / D14)。
3
- *
4
- * 覆盖面:
5
- * - D14 args 校验(场景 24 单元层):传入 args 与 run-created 帧 argsSummary 的
6
- * 逐字段深度比对(排除 _runId)——不一致明确拒绝且差异字段可见;一致幂等通过;
7
- * 不传默认沿用历史(D14 层不拦)。场景级(真链路)用例由 u4b 落地。
8
- * - argsSummary 截断形态的保守拒绝(全文 args 不进 record,截断摘要不可比对)。
9
- * - record 流无 run-created 帧 → D14 层透传(资格判据归 core resumeRun 权威文案)。
10
- * - resumeRun 接线:budgetTimeMs(time 形参透传)/ host / runId;成功文案与 details。
11
- * - diffResumeArgs 纯逻辑(差异三形态 + 嵌套 + 数组 + _runId 排除)。
12
- *
13
- * mock 策略:resume-run 深路径 stub(resumeRun 为 vi.fn——不起锁/IO,只测入口
14
- * 面与校验层);record 流用 mkdtemp 真文件(D14 读取面读真实路径)。范式对齐
15
- * tool-workflow.test.ts(lifecycle 深路径 mock + captureTool 注册层黑盒)。
16
- */
17
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
- import { tmpdir } from "node:os";
19
- import { join } from "node:path";
20
-
21
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
-
23
- // 被 mock 的模块——import 路径与被测源文件的等值实例(vi.mock hoist 后 barrel
24
- // re-export 与深路径指向同一 mock 实例,对齐 tool-workflow.test.ts 先例)
25
- import { resumeRun } from "@zhushanwen/subagent-core/orchestration/resume-run.ts";
26
-
27
- import { actionResume, diffResumeArgs } from "../tool-workflow.ts";
28
-
29
- /** 桩化 resume-run——resumeRun 为 vi.fn(不起锁/record IO,只测入口校验与接线面)。 */
30
- vi.mock("@zhushanwen/subagent-core/orchestration/resume-run.ts", () => ({
31
- resumeRun: vi.fn(),
32
- }));
33
-
34
- // ── fixture ──────────────────────────────────────────────────
35
-
36
- let tmpDir: string;
37
-
38
- beforeEach(() => {
39
- vi.mocked(resumeRun).mockReset();
40
- vi.mocked(resumeRun).mockResolvedValue("wf-test");
41
- tmpDir = mkdtempSync(join(tmpdir(), "u3-resume-"));
42
- });
43
-
44
- afterEach(() => {
45
- rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
46
- vi.restoreAllMocks();
47
- });
48
-
49
- /** 写 record 流(每行一个 JSON 对象)并返回路径——D14 读取面的真实文件。 */
50
- function writeRecordStream(lines: unknown[]): string {
51
- const path = join(tmpDir, "wf-test.record.jsonl");
52
- writeFileSync(path, lines.map((l) => JSON.stringify(l)).join("\n") + "\n", "utf8");
53
- return path;
54
- }
55
-
56
- /** run-created 帧(args 含 _runId——对齐 lifecycle runWorkflow 注入后落账形态)。 */
57
- function runCreatedFrame(argsSummary: string) {
58
- return {
59
- type: "run-created",
60
- seq: 1,
61
- ts: 1_700_000_000_000,
62
- runId: "wf-test",
63
- workflowName: "demo",
64
- argsSummary,
65
- scriptSource: "// demo",
66
- };
67
- }
68
-
69
- /** 最小 deps stub(actionResume 只触 store.stateFilePath 与 resumeRun(deps))。 */
70
- function makeDeps(stateFilePath: string): unknown {
71
- return {
72
- runs: new Map(),
73
- store: { stateFilePath: () => stateFilePath },
74
- registry: { get: vi.fn(), getPath: vi.fn(), loadAll: vi.fn() },
75
- };
76
- }
77
-
78
- // ── diffResumeArgs 纯逻辑 ────────────────────────────────────
79
-
80
- describe("diffResumeArgs(D14 深度比对)", () => {
81
- it("值不等 → 差异字段可见(场景 24 前半:{a:1} 历史以 {a:2} 发)", () => {
82
- expect(diffResumeArgs({ a: 2 }, { a: 1, _runId: "wf-test" })).toEqual([
83
- "args.a: resume 2 vs original 1",
84
- ]);
85
- });
86
-
87
- it("一致 → 空(场景 24 后半:{a:1} 幂等通过)", () => {
88
- expect(diffResumeArgs({ a: 1 }, { a: 1, _runId: "wf-test" })).toEqual([]);
89
- });
90
-
91
- it("_runId 双侧排除(incoming 侧携带同样不比)", () => {
92
- expect(diffResumeArgs({ a: 1, _runId: "other" }, { a: 1, _runId: "wf-test" })).toEqual([]);
93
- });
94
-
95
- it("仅 resume 侧有 → unexpected 形态", () => {
96
- expect(diffResumeArgs({ a: 1, b: 2 }, { a: 1 })).toEqual([
97
- "args.b: resume args only (not in original run)",
98
- ]);
99
- });
100
-
101
- it("仅原 run 侧有 → missing 形态", () => {
102
- expect(diffResumeArgs({ a: 1 }, { a: 1, b: "x" })).toEqual([
103
- "args.b: original run only (missing from resume args)",
104
- ]);
105
- });
106
-
107
- it("嵌套 plain object 递归(路径 a.b 形态)", () => {
108
- expect(
109
- diffResumeArgs({ cfg: { deep: 1, same: "s" } }, { cfg: { deep: 2, same: "s" } }),
110
- ).toEqual(["args.cfg.deep: resume 1 vs original 2"]);
111
- });
112
-
113
- it("数组按值整体比(不递归元素)", () => {
114
- expect(diffResumeArgs({ items: [1, 2] }, { items: [1, 3] })).toEqual([
115
- 'args.items: resume [1,2] vs original [1,3]',
116
- ]);
117
- expect(diffResumeArgs({ items: [1, 2] }, { items: [1, 2] })).toEqual([]);
118
- });
119
- });
120
-
121
- // ── actionResume:入口校验与接线 ─────────────────────────────
122
-
123
- describe("actionResume", () => {
124
- it("runId 缺失 → throw 用法指引(不触 resumeRun)", async () => {
125
- await expect(
126
- actionResume({ action: "resume" } as never, makeDeps("/nonexistent") as never),
127
- ).rejects.toThrow(/resume requires 'runId'/);
128
- expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
129
- });
130
-
131
- it("args 与历史不一致 → 拒绝且差异字段可见,不触 resumeRun(场景 24 前半)", async () => {
132
- const recordPath = writeRecordStream([runCreatedFrame('{"a":1,"_runId":"wf-test"}')]);
133
- const err = await actionResume(
134
- { action: "resume", runId: "wf-test", args: { a: 2 } } as never,
135
- makeDeps(recordPath) as never,
136
- ).catch((e: unknown) => e as Error);
137
- expect(err).toBeInstanceOf(Error);
138
- expect(err.message).toContain("differ from the original run");
139
- expect(err.message).toContain("args.a: resume 2 vs original 1");
140
- expect(err.message).toContain("start a new run");
141
- expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
142
- });
143
-
144
- it("args 与历史一致 → 幂等通过(场景 24 后半),resumeRun 接线生效", async () => {
145
- const recordPath = writeRecordStream([runCreatedFrame('{"a":1,"_runId":"wf-test"}')]);
146
- const result = (await actionResume(
147
- { action: "resume", runId: "wf-test", args: { a: 1 } } as never,
148
- makeDeps(recordPath) as never,
149
- )) as { content: Array<{ text: string }>; details: Record<string, unknown> };
150
- expect(vi.mocked(resumeRun)).toHaveBeenCalledTimes(1);
151
- const [calledRunId, calledDeps, options] = vi.mocked(resumeRun).mock.calls[0]!;
152
- expect(calledRunId).toBe("wf-test");
153
- expect(calledDeps).toBeDefined();
154
- expect((options as { budgetTimeMs?: number }).budgetTimeMs).toBeUndefined();
155
- expect(typeof (options as { host?: string }).host).toBe("string");
156
- expect(result.content[0]!.text).toContain("Resuming workflow run wf-test");
157
- expect(result.details).toMatchObject({ action: "resume", runId: "wf-test", status: "running" });
158
- });
159
-
160
- it("args 缺省 → 默认沿用历史(D14 层不拦,不读 record)", async () => {
161
- await actionResume(
162
- { action: "resume", runId: "wf-test" } as never,
163
- makeDeps(join(tmpDir, "missing.record.jsonl")) as never,
164
- );
165
- expect(vi.mocked(resumeRun)).toHaveBeenCalledTimes(1);
166
- });
167
-
168
- it("record 流无 run-created 帧 → D14 层透传(资格判据归 resumeRun 权威文案)", async () => {
169
- const recordPath = writeRecordStream([
170
- { type: "phase-started", seq: 1, ts: 1, phase: "p1" },
171
- ]);
172
- await actionResume(
173
- { action: "resume", runId: "wf-test", args: { a: 1 } } as never,
174
- makeDeps(recordPath) as never,
175
- );
176
- expect(vi.mocked(resumeRun)).toHaveBeenCalledTimes(1);
177
- });
178
-
179
- it("argsSummary 截断 → 保守拒绝(无法逐字段比对;引导免 args resume 或开新 run)", async () => {
180
- const long = "x".repeat(300);
181
- const recordPath = writeRecordStream([runCreatedFrame(`{"payload":"${long}","_runId":"wf-test"}…`)]);
182
- const err = await actionResume(
183
- { action: "resume", runId: "wf-test", args: { payload: long } } as never,
184
- makeDeps(recordPath) as never,
185
- ).catch((e: unknown) => e as Error);
186
- expect(err).toBeInstanceOf(Error);
187
- expect(err.message).toContain("exceed the record's args summary limit");
188
- expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
189
- });
190
-
191
- it("argsSummary 被篡改为非法 JSON → 拒绝含恢复指引(不含裸 SyntaxError;U21 与 malformed 分支同构)", async () => {
192
- // 字段篡改形态:未截断摘要字符串是非法 JSON(JSON.stringify 只把它当字符串值
193
- // 序列化,读取面 parse 必炸)——catch 后抛结构化拒绝文案,原生 SyntaxError
194
- // 不透出。
195
- const recordPath = writeRecordStream([runCreatedFrame("{broken json")]);
196
- const err = await actionResume(
197
- { action: "resume", runId: "wf-test", args: { a: 1 } } as never,
198
- makeDeps(recordPath) as never,
199
- ).catch((e: unknown) => e as Error);
200
- expect(err).toBeInstanceOf(Error);
201
- expect(err.message).toContain("Resume rejected: original args of run wf-test are malformed");
202
- expect(err.message).toContain("Recovery: inspect the record file");
203
- expect(err.message).toContain("start a new run");
204
- expect(err.message).not.toContain("SyntaxError");
205
- expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
206
- });
207
-
208
- it("time 形参透传 budgetTimeMs(u2 偏差②:预算经 D14 同款通道传入)", async () => {
209
- const recordPath = writeRecordStream([runCreatedFrame('{"a":1}')]);
210
- await actionResume(
211
- { action: "resume", runId: "wf-test", args: { a: 1 }, time: 120_000 } as never,
212
- makeDeps(recordPath) as never,
213
- );
214
- const options = vi.mocked(resumeRun).mock.calls[0]![2] as { budgetTimeMs?: number };
215
- expect(options.budgetTimeMs).toBe(120_000);
216
- });
217
-
218
- it("time 负值 → 入口护栏拒绝(与 run action 同源 assertEntryTimeBudget)", async () => {
219
- await expect(
220
- actionResume(
221
- { action: "resume", runId: "wf-test", time: -1 } as never,
222
- makeDeps("/nonexistent") as never,
223
- ),
224
- ).rejects.toThrow(/time/);
225
- expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
226
- });
227
- });
@@ -1,81 +0,0 @@
1
- /**
2
- * GUI 协议映射辅助函数 —— run/subagent 状态字符串 → 协议 TreeItem 状态 + 图标。
3
- *
4
- * 协议包 @zhushanwen/extension-protocol 的 list-tree 组件用 TreeItem.status(三态)
5
- * + TreeItem.icon 表达运行态。本模块把 workflow/subagent 领域的丰富状态字符串收口
6
- * 到这两个枚举,供 helpers.ts / tool-workflow.ts / subagent-actions.ts 复用。
7
- *
8
- * 参考:@zhushanwen/extension-protocol GuiComponentProps['list-tree']。
9
- */
10
-
11
- import type { GuiContext, TreeItem, TreeItemIcon } from "@zhushanwen/extension-protocol";
12
-
13
- /**
14
- * 从 Pi ExtensionContext 构造协议 GuiContext 的最小子集。
15
- *
16
- * Pi SDK 的 ExtensionContext 在结构上满足协议 GuiContext(有 mode/hasUI/ui),
17
- * 但 ui.custom 的泛型签名与协议 GuiContext.ui.custom 不兼容(前者复杂泛型,后者
18
- * 简化签名),直接 `as GuiContext` 会触发 TS 结构兼容错误(ui.custom 参数逆变)。
19
- * 此 helper 显式提取 mode/hasUI,构造最小 GuiContext,规避 ui.custom 签名冲突。
20
- *
21
- * 与 ask-user extension 的 runRpcInteraction 同构(见 ask-user/src/index.ts)。
22
- */
23
- export function toGuiCtx(ctx: { mode: GuiContext["mode"]; hasUI: boolean } | undefined): GuiContext | undefined {
24
- if (!ctx) return undefined;
25
- return { mode: ctx.mode, hasUI: ctx.hasUI };
26
- }
27
-
28
- /** TreeItem.status 枚举(协议三态)。 */
29
- type TreeStatus = NonNullable<TreeItem["status"]>;
30
-
31
- /**
32
- * 状态字符串是否为失败态(mapRunStatus/mapRunIcon 共享谓词——两映射仅返回值形态不同,
33
- * 关键词表必须同步演化,抽单点防双写漂移)。入参须已 toLowerCase。
34
- */
35
- function isFailedStatus(s: string): boolean {
36
- return (
37
- s.includes("failed") ||
38
- s.includes("abort") ||
39
- s.includes("cancel") ||
40
- s.includes("crash") ||
41
- s.includes("error") ||
42
- s.includes("budget") ||
43
- s.includes("time_limited")
44
- );
45
- }
46
-
47
- /**
48
- * 把 workflow/subagent 状态字符串映射到 list-tree 的三态 status。
49
- *
50
- * 输入可能是纯 RunStatus(running/done)、RunStatus+reason 组合
51
- * (如 "done (failed)"),或 subagent status(running/idle/legacy done/failed/
52
- * cancelled/crashed)。
53
- *
54
- * 映射规则:
55
- * - running → running
56
- * - failed / aborted / error / crashed / cancelled / budget_limited / time_limited → failed
57
- * - 其他(done / completed / success / pending / idle)→ done
58
- *
59
- * [U8 两态] subagent 的 idle(无任务在飞可续聊)在协议三态里落 done(空闲 = 无
60
- * 进行中工作,check 图标);「为什么停」不进树形状态(协议无该维度)。
61
- */
62
- export function mapRunStatus(status: string): TreeStatus {
63
- const s = status.toLowerCase();
64
- if (s.includes("running")) return "running";
65
- if (isFailedStatus(s)) return "failed";
66
- return "done";
67
- }
68
-
69
- /**
70
- * 把状态字符串映射到 TreeItem.icon。
71
- *
72
- * running → circle(进行中)
73
- * failed/abort/cancel/crash → cross
74
- * 其他(done) → check
75
- */
76
- export function mapRunIcon(status: string): TreeItemIcon {
77
- const s = status.toLowerCase();
78
- if (s.includes("running")) return "circle";
79
- if (isFailedStatus(s)) return "cross";
80
- return "check";
81
- }