@tea-agent/loop-agent 0.39.0-next.24 → 0.39.0-next.26

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 (34) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -0
  3. package/dist/build-stamp.json +2 -2
  4. package/dist/executors/pi-executor.js +51 -0
  5. package/dist/executors/pi-sdk-executor.js +108 -57
  6. package/dist/executors/shell-executor.js +55 -1
  7. package/dist/worker/console/pi-readiness.js +4 -4
  8. package/dist/worker/observe/static/state.js +2 -2
  9. package/dist/worker/observe/static/views/session-timeline.js +18 -5
  10. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +54 -2
  11. package/dist/workflows/dag/backend-test-scenario-partitions.js +73 -1
  12. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  13. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  14. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  15. package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
  16. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  17. package/dist/workflows/dag/init-hybrid.js +10 -18
  18. package/dist/workflows/dag/rerun-plan.js +22 -3
  19. package/dist/workflows/dag/types.js +4 -0
  20. package/dist/workflows/dag/validate.js +2 -0
  21. package/docs/operations/local-development-environment.md +1 -5
  22. package/docs/skills/vetted-skill-registry.md +14 -0
  23. package/docs/templates/README.md +1 -1
  24. package/docs/templates/agent-dag.schema.json +31 -0
  25. package/docs/templates/backend-test-dag.json +2 -2
  26. package/docs/templates/frontend-test-dag.json +8 -10
  27. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  28. package/package.json +8 -4
  29. package/skills/codebase-scout/SKILL.md +1 -1
  30. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  31. package/skills/improve-codebase-architecture/deepening.md +37 -0
  32. package/skills/improve-codebase-architecture/html-report.md +123 -0
  33. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  34. package/skills/improve-codebase-architecture/language.md +53 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  ### 变更
6
6
 
7
+ - 将 `@earendil-works/pi-ai` 与 `@earendil-works/pi-coding-agent` 从可选依赖提升为精确 pin 的运行时依赖 `0.83.0`,将运行时直接导入的 `typebox` 精确声明为 `1.3.7`,并在根包声明 Node.js `>=22.19.0`。安装 `@tea-agent/loop-agent` 会默认装上 Pi SDK(含 `--omit=optional` 场景);`@cursor/sdk` 仍保持 optional。availability / readiness 探针、隔离 tarball canary、发布包契约测试与安装/本地环境说明同步对齐。
8
+ - backend-test Scenario Partition 仅接受绑定源有限 enum/分类集合的 GET/list query/path 轴;非法 Domain(空/重复/保留 token、无有限域、与 OpenAPI enum 不一致)报 `INVALID_PARTITION_DOMAIN` / `NO_FINITE_DOMAIN` / `PARTITION_SOURCE_MISMATCH` 根因,不再生成空后缀 `TP-SP-...-`、重复 slot,也不再级联 `MISSING_*_SLOT`。N6 对这些根因码 fail-closed;合法源分区缺槽仍阻断。N2 禁止为自由串/主键/required-only/boundary-only 建分区;N5 允许删除无源非法 Partition 及其派生槽位。
7
9
  - npm `next` 渠道发布改为提交数节流:main push 仅当距上次 next 发布的 source commit ≥ 5 个 commit 时才自动发布(新增 `scripts/next-publish-gate.mjs`,读已发布 tarball build-stamp 对比 HEAD);不足阈值时 workflow 绿色跳过,手动 `workflow_dispatch` 不受限(切 release 线前强制出包 / 紧急补发)。发布文档真源 §4.1/§5.1/§7/§12 同步。
8
10
 
9
11
  ### 新增
@@ -31,6 +33,12 @@
31
33
 
32
34
  ### 改进
33
35
 
36
+ - frontend-test prepare-package 会从 `docs/templates/`、`harness.json` `governanceRoot`/templates 以及 init 投影的 `ai_workspace/loop-agent/templates/` 拷贝标准场景;full init 目标仓不再静默落到仅含 `STD-FE-SMOKE-ENTRY` 的 fallback。
37
+ - frontend-test 环境探测区分 connection-refused(curl 7)与 HTTP 4xx/5xx,并提示先启动本地前端后再从 probe 节点续跑;不再把所有不可达都写成同一句 `frontend-base-url-unreachable`。
38
+ - frontend-test 报告解析用例 Markdown 的 `##`/`###` 标题与有序列表(`测试步骤` / `预期结果` / `前置条件与重置`);缺 `测试目的` 时回退到用例 H1,避免主 HTML 出现空步骤。
39
+ - `dag rerun --from-node` 把尚未启动的 writer/map 视为首次执行;frontend-test probe 这种失败的 exclusive shell 可以带着下游未跑节点续跑,不必整图 `standaloneTaskRerun`。
40
+ - Pi SDK 节点在 stall watchdog(默认 15 分钟无 provider 活动)触发后,会先 abort 当前等待,并在同一会话自动发送一次「继续」;仅 abort 已确认且尚未调用 write 工具时恢复,失败仍判 timeout。绝对墙钟超时与 CLI 后端不走这条恢复。
41
+ - DAG 节点检查器「执行过程」时间线默认展开原始协议事件(`message_start` / `message_end`),按钮文案为「隐藏原始协议事件」;用户可点选关闭,同会话内选择会保持。
34
42
  - Operator Chat 的乐观停止现在把“视觉已停止”和 Turn ownership 分开:Abort 获得可靠终态前明确显示“正在停止”,此时可为原会话保存一条服务端持久化的“停止后发送”消息;后一次覆盖前一次,切换会话、刷新页面或 Console 重启不会丢失,旧 Turn 结束后复用正常 Turn 准入路径自动发送,并以固定 `clientRequestId` 防止重复执行。
35
43
  - Operator Chat 修复快速终止时“停止后发送”与普通发送之间的准入空洞:发送路径改读实时 ownership;若 deferred 请求恰好撞上旧 Turn 终态,会先按服务端状态对账,再使用同一 `clientRequestId` 自动转为普通发送,不再要求用户刷新重试或丢失本次提交。
36
44
  - `dag rerun-task` 现在会把父 run 最后一轮仍未解决的 `verify-pi` / review 反馈、证据引用和修订轮次带入新完整 DAG;planner、implementer 与 verifier/reviewer 必须修复或举证说明不再适用,不能仅因文件已存在就返回 `already-satisfied`。Console 同步区分「自动修订」「从失败节点恢复」「带反馈重新执行任务」。
package/README.md CHANGED
@@ -24,6 +24,8 @@
24
24
 
25
25
  ### 安装
26
26
 
27
+ 需要 Node.js `>=22.19.0`。建议先运行 `node --version` 确认环境满足要求;启用 npm `engine-strict` 时,旧版本 Node.js 会直接拒绝安装。
28
+
27
29
  ```bash
28
30
  npm install -g @tea-agent/loop-agent@latest
29
31
  loop-agent --version
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "version": "0.37.0",
4
- "gitSha": "b89dac83463a5cf86ebac304b93bf894ecc53dc8",
5
- "builtAt": "2026-08-20T16:19:50.694Z"
4
+ "gitSha": "261086956566acadb9df5ed94b528938cf8d058e",
5
+ "builtAt": "2026-08-21T12:44:01.132Z"
6
6
  }
@@ -408,6 +408,50 @@ export class BoundedTextPreview {
408
408
  ].join("\n");
409
409
  }
410
410
  }
411
+ /**
412
+ * Normalize provider-specific completion-limit labels to the DAG's canonical
413
+ * `length` signal. Other terminal reasons remain observable without being
414
+ * treated as truncation.
415
+ */
416
+ export function normalizeProviderStopReason(value) {
417
+ if (typeof value !== "string" || value.trim().length === 0)
418
+ return undefined;
419
+ const trimmed = value.trim();
420
+ const compact = trimmed.toLowerCase().replace(/[\s_-]/g, "");
421
+ if ([
422
+ "length",
423
+ "maxtokens",
424
+ "maxoutputtokens",
425
+ "outputlimit",
426
+ "tokenlimit",
427
+ "tokenlimitreached",
428
+ ].includes(compact)) {
429
+ return "length";
430
+ }
431
+ return trimmed;
432
+ }
433
+ /**
434
+ * Extract a terminal stop reason from the provider event shapes used by Pi
435
+ * SDK/CLI bridges. Providers disagree on `stop*` versus `finish*`; keep this
436
+ * boundary centralized so downstream DAG recovery sees one canonical signal.
437
+ */
438
+ export function extractPiEventStopReason(event) {
439
+ const nested = [event.message, event.response, event.completion].filter(isRecord);
440
+ const containers = [...nested, event];
441
+ for (const container of containers) {
442
+ for (const key of [
443
+ "stopReason",
444
+ "stop_reason",
445
+ "finishReason",
446
+ "finish_reason",
447
+ ]) {
448
+ const normalized = normalizeProviderStopReason(container[key]);
449
+ if (normalized)
450
+ return normalized;
451
+ }
452
+ }
453
+ return undefined;
454
+ }
411
455
  export class PiJsonlStreamCollector {
412
456
  lineBuffer = "";
413
457
  discardUntilNewline = false;
@@ -415,6 +459,7 @@ export class PiJsonlStreamCollector {
415
459
  parsedEvents = 0;
416
460
  tokensUsed = 0;
417
461
  outputTooLarge = false;
462
+ stopReason;
418
463
  subagentTotalCalls = 0;
419
464
  subagentFailedCalls = 0;
420
465
  subagentModes = new Set();
@@ -454,6 +499,7 @@ export class PiJsonlStreamCollector {
454
499
  assistantText: this.assistantText.trim(),
455
500
  outputTooLarge: this.outputTooLarge,
456
501
  parsedEvents: this.parsedEvents,
502
+ stopReason: this.stopReason,
457
503
  subagentStats: this.formatSubagentStats(),
458
504
  tokensUsed: this.tokensUsed,
459
505
  };
@@ -472,6 +518,10 @@ export class PiJsonlStreamCollector {
472
518
  }
473
519
  }
474
520
  consumeEvent(event) {
521
+ const stopReason = extractPiEventStopReason(event);
522
+ if (stopReason === "length" || !this.stopReason) {
523
+ this.stopReason = stopReason ?? this.stopReason;
524
+ }
475
525
  const candidate = extractAssistantTextFromEvent(event);
476
526
  if (candidate !== undefined) {
477
527
  if (candidate.length > MAX_ASSISTANT_TEXT_CHARS) {
@@ -865,6 +915,7 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
865
915
  stdoutTruncated: stdoutPreview.truncated,
866
916
  stderrTruncated: stderrPreview.truncated,
867
917
  outputTooLarge: collected.outputTooLarge,
918
+ stopReason: collected.stopReason,
868
919
  timedOut,
869
920
  tokensUsed: collected.tokensUsed,
870
921
  subagentStats: collected.subagentStats,
@@ -1,8 +1,15 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from "./pi-executor.js";
3
+ import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractPiEventStopReason, extractAssistantTextFromPiJson, } from "./pi-executor.js";
4
4
  import { serializeSessionEvent } from "./pi-event-serializer.js";
5
5
  import { PI_RECOMMENDED_COMPACTION, PI_RECOMMENDED_RETRY, } from "../shared/pi-retry-settings.js";
6
+ /** Same-session recovery after a transport stall. Exactly one continue per SDK attempt. */
7
+ export const PI_SDK_STALL_CONTINUE_MAX = 1;
8
+ /**
9
+ * Fixed follow-up sent after aborting a stalled prompt.
10
+ * Kept short so the model can resume without repeating the original task payload.
11
+ */
12
+ export const PI_SDK_STALL_CONTINUE_PROMPT = "继续。上一轮因长时间无 provider 活动已被中止;请从中断处接着完成,不要重复已完成的步骤。若此前没有有效输出,请重新执行原任务。";
6
13
  let sdkSessionFactoryOverride;
7
14
  let sdkImportOverrideForTests;
8
15
  let sdkModuleOverrideForTests;
@@ -44,15 +51,15 @@ export async function resetPiSdkReuseScopeForTests() {
44
51
  export function setPiSdkSessionFactoryForTests(factory) {
45
52
  sdkSessionFactoryOverride = factory;
46
53
  }
47
- /** Test hook: simulate SDK import success/failure without relying on optionalDependency install state. */
54
+ /** Test hook: simulate SDK import success/failure without relying on installed SDK state. */
48
55
  export function setPiSdkImportOverrideForTests(fn) {
49
56
  sdkImportOverrideForTests = fn;
50
57
  }
51
- /** Test hook: inject a mock Pi SDK module to exercise resolveSdkSessionFactory without optionalDependency. */
58
+ /** Test hook: inject a mock Pi SDK module to exercise resolveSdkSessionFactory without loading the real SDK. */
52
59
  export function setPiSdkModuleOverrideForTests(fn) {
53
60
  sdkModuleOverrideForTests = fn;
54
61
  }
55
- /** Check whether the Pi SDK optional dependency satisfies the 0.80.10 runtime contract. */
62
+ /** Check whether the Pi SDK runtime dependency satisfies the 0.83.0 runtime contract. */
56
63
  export async function checkPiSdkAvailability(_repoRoot) {
57
64
  if (sdkSessionFactoryOverride) {
58
65
  return { ok: true, detail: "pi SDK session factory override active" };
@@ -68,10 +75,10 @@ export async function checkPiSdkAvailability(_repoRoot) {
68
75
  typeof ModelRuntime?.create !== "function") {
69
76
  return {
70
77
  ok: false,
71
- detail: "pi SDK incompatible: requires createAgentSession, getAgentDir, and ModelRuntime.create (0.80.10 contract)",
78
+ detail: "pi SDK incompatible: requires createAgentSession, getAgentDir, and ModelRuntime.create (0.83.0 contract)",
72
79
  };
73
80
  }
74
- return { ok: true, detail: "pi SDK 0.80.10 contract available" };
81
+ return { ok: true, detail: "pi SDK 0.83.0 contract available" };
75
82
  }
76
83
  catch (error) {
77
84
  const message = error instanceof Error ? error.message : String(error);
@@ -404,25 +411,6 @@ function extractSdkUsageSample(event) {
404
411
  const responseKey = responseKeyCandidates.find((value) => typeof value === "string" && value.length > 0);
405
412
  return responseKey ? { responseKey, tokens } : { tokens };
406
413
  }
407
- /** Extract a non-empty stop reason from a terminal SDK event.
408
- * Probes multiple field shapes across SDK versions so a missing field fails
409
- * open (undefined) rather than misclassifying an attempt. */
410
- function readStopReason(event) {
411
- const message = isRecord(event.message) ? event.message : undefined;
412
- const candidates = [
413
- message?.stop_reason,
414
- message?.stopReason,
415
- event.stopReason,
416
- event.stop_reason,
417
- ];
418
- for (const candidate of candidates) {
419
- if (typeof candidate === "string" &&
420
- candidate.trim().length > 0) {
421
- return candidate.trim();
422
- }
423
- }
424
- return undefined;
425
- }
426
414
  function aggregateSdkTokenUsage(samples) {
427
415
  const identified = new Map();
428
416
  let anonymousMaximum = 0;
@@ -444,6 +432,8 @@ function aggregateSdkTokenUsage(samples) {
444
432
  * When reuseScope is active, only shared auth/model resources are reused; each attempt still
445
433
  * creates and disposes its own session and resource loader. Session reuse across steps is
446
434
  * deferred until a proven reset/isolation strategy exists.
435
+ * A transport stall may abort the current prompt and send one continue on the
436
+ * same session; that is in-attempt recovery, not cross-step session reuse.
447
437
  */
448
438
  export async function executeSingleSdkAttempt(options) {
449
439
  const modelConfig = options.modelConfig;
@@ -494,7 +484,7 @@ export async function executeSingleSdkAttempt(options) {
494
484
  type === "message_end" ||
495
485
  type === "agent_end" ||
496
486
  type === "agent_settled") {
497
- const candidate = readStopReason(event);
487
+ const candidate = extractPiEventStopReason(event);
498
488
  if (candidate)
499
489
  stopReason = candidate;
500
490
  }
@@ -559,16 +549,30 @@ export async function executeSingleSdkAttempt(options) {
559
549
  let resolveStall;
560
550
  let resolveSettlement;
561
551
  let settled = false;
562
- const settlementPromise = new Promise((resolve) => {
563
- resolveSettlement = resolve;
564
- });
565
- const stallPromise = stallTimeoutMs > 0
566
- ? new Promise((resolve) => {
552
+ let abortConfirmed = true;
553
+ const persistRuntimeNote = (event) => {
554
+ const line = serializeSessionEvent(event);
555
+ stdoutPreview.append(`${line}\n`);
556
+ stdoutCollector.append(`${line}\n`);
557
+ sessionEventAppender?.append(line, event);
558
+ };
559
+ const resetSettlement = () => {
560
+ settled = false;
561
+ return new Promise((resolve) => {
562
+ resolveSettlement = resolve;
563
+ });
564
+ };
565
+ const resetStallPromise = () => {
566
+ if (stallTimeoutMs <= 0) {
567
+ resolveStall = undefined;
568
+ return null;
569
+ }
570
+ return new Promise((resolve) => {
567
571
  resolveStall = resolve;
568
- })
569
- : null;
572
+ });
573
+ };
570
574
  const armStallWatchdog = () => {
571
- if (!stallPromise || !resolveStall)
575
+ if (stallTimeoutMs <= 0 || !resolveStall)
572
576
  return;
573
577
  if (stallHandle)
574
578
  clearTimeout(stallHandle);
@@ -609,17 +613,6 @@ export async function executeSingleSdkAttempt(options) {
609
613
  stdoutCollector.append(`${line}\n`);
610
614
  sessionEventAppender?.append(line, event);
611
615
  });
612
- const filePrefix = options.attachedFiles
613
- .map((file) => `@${file}`)
614
- .join(" ");
615
- const promptMessage = filePrefix
616
- ? `${filePrefix}\n${options.userMessage}`
617
- : options.userMessage;
618
- const promptPromise = session.prompt(promptMessage);
619
- // A settlement event is permitted to win the race while prompt() remains
620
- // pending. Observe a later rejection so it never becomes unhandled.
621
- void promptPromise.catch(() => { });
622
- armStallWatchdog();
623
616
  const timeoutPromise = timeoutMs > 0
624
617
  ? new Promise((resolve) => {
625
618
  timeoutHandle = setTimeout(() => {
@@ -627,21 +620,79 @@ export async function executeSingleSdkAttempt(options) {
627
620
  }, timeoutMs);
628
621
  })
629
622
  : null;
630
- const raced = await Promise.race([
631
- promptPromise.then(() => "done"),
632
- settlementPromise,
633
- ...(timeoutPromise ? [timeoutPromise] : []),
634
- ...(stallPromise ? [stallPromise] : []),
635
- ]);
636
- if (raced !== "done" && raced !== "settled") {
637
- timedOut = true;
638
- appendStderr(raced === "stall"
639
- ? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
640
- : `pi SDK step timed out after ${timeoutMs}ms`);
641
- const abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
623
+ const runTurn = async (userMessage) => {
624
+ const settlementPromise = resetSettlement();
625
+ const stallPromise = resetStallPromise();
626
+ const promptPromise = session.prompt(userMessage);
627
+ // A settlement event is permitted to win the race while prompt() remains
628
+ // pending. Observe a later rejection so it never becomes unhandled.
629
+ void promptPromise.catch(() => { });
630
+ armStallWatchdog();
631
+ return Promise.race([
632
+ promptPromise.then(() => "done"),
633
+ settlementPromise,
634
+ ...(timeoutPromise ? [timeoutPromise] : []),
635
+ ...(stallPromise ? [stallPromise] : []),
636
+ ]);
637
+ };
638
+ const disposeSession = async () => {
642
639
  disposeAttempted = true;
643
640
  const disposeConfirmed = await runSessionActionWithGrace("dispose", () => session.dispose());
644
641
  terminationConfirmed = abortConfirmed && disposeConfirmed;
642
+ };
643
+ const failClosedAfterWait = async (reason, extraStderr, abortFirst = true) => {
644
+ timedOut = true;
645
+ appendStderr(reason === "stall"
646
+ ? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
647
+ : `pi SDK step timed out after ${timeoutMs}ms`);
648
+ if (extraStderr)
649
+ appendStderr(extraStderr);
650
+ if (abortFirst) {
651
+ abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
652
+ }
653
+ await disposeSession();
654
+ };
655
+ const filePrefix = options.attachedFiles
656
+ .map((file) => `@${file}`)
657
+ .join(" ");
658
+ const promptMessage = filePrefix
659
+ ? `${filePrefix}\n${options.userMessage}`
660
+ : options.userMessage;
661
+ let continuesSent = 0;
662
+ let raced = await runTurn(promptMessage);
663
+ if (raced === "stall") {
664
+ abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
665
+ const withinAbsoluteTimeout = timeoutMs <= 0 || Date.now() - startedAt < timeoutMs;
666
+ const canContinue = abortConfirmed &&
667
+ writeToolCallCount === 0 &&
668
+ withinAbsoluteTimeout &&
669
+ continuesSent < PI_SDK_STALL_CONTINUE_MAX;
670
+ if (canContinue) {
671
+ // The aborted turn's stopReason must not leak onto a recovered continue.
672
+ stopReason = undefined;
673
+ continuesSent += 1;
674
+ persistRuntimeNote({
675
+ type: "loop-agent-stall-continue",
676
+ prompt: PI_SDK_STALL_CONTINUE_PROMPT,
677
+ maxContinues: PI_SDK_STALL_CONTINUE_MAX,
678
+ });
679
+ raced = await runTurn(PI_SDK_STALL_CONTINUE_PROMPT);
680
+ if (raced !== "done" && raced !== "settled") {
681
+ await failClosedAfterWait(raced === "stall" ? "stall" : "absolute-timeout", raced === "stall"
682
+ ? "pi SDK stall continue did not recover; treating as timeout"
683
+ : "pi SDK stall continue hit the absolute timeout", true);
684
+ }
685
+ }
686
+ else {
687
+ await failClosedAfterWait("stall", !abortConfirmed
688
+ ? undefined
689
+ : writeToolCallCount > 0
690
+ ? "pi SDK stall continue skipped because write tools already ran"
691
+ : "pi SDK stall continue skipped because absolute timeout elapsed", false);
692
+ }
693
+ }
694
+ else if (raced === "absolute-timeout") {
695
+ await failClosedAfterWait("absolute-timeout");
645
696
  }
646
697
  }
647
698
  catch (error) {
@@ -15,6 +15,8 @@ import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend
15
15
  import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
16
16
  import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
17
17
  import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
18
+ import { copyFrontendTestStandardScenarios } from "../workflows/dag/frontend-test-standard-scenarios.js";
19
+ import { probeFrontendTestEnvironment } from "../workflows/dag/frontend-test-environment-probe.js";
18
20
  import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
19
21
  import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
20
22
  import { materializeFrontendTestCaseManifest } from "../workflows/dag/frontend-test-case-manifest.js";
@@ -704,7 +706,7 @@ async function executeBackendTestPipeline(input, meta) {
704
706
  // coverage findings stay advisory, but a declared filter axis missing
705
707
  // an each-value/omitted slot or its complement slot must block pytest
706
708
  // generation until the Markdown declares the slot.
707
- const partitionBlocking = coverage.facts.findings.filter((finding) => /^(?:MISSING_PARTITION|MISSING_PARTITION_SLOT|MISSING_COMPLEMENT_SLOT|MISSING_PARTITION_TABLE)/.test(finding));
709
+ const partitionBlocking = coverage.facts.findings.filter((finding) => /^(?:MISSING_PARTITION|MISSING_PARTITION_SLOT|MISSING_COMPLEMENT_SLOT|MISSING_PARTITION_TABLE|INVALID_PARTITION_DOMAIN|NO_FINITE_DOMAIN|PARTITION_SOURCE_MISMATCH)/.test(finding));
708
710
  if (partitionBlocking.length > 0) {
709
711
  return {
710
712
  ok: false,
@@ -2061,6 +2063,52 @@ async function executeFrontendTestHtmlReport(input, meta) {
2061
2063
  };
2062
2064
  }
2063
2065
  }
2066
+ async function executeFrontendTestStandardScenarios(input) {
2067
+ const started = Date.now();
2068
+ try {
2069
+ const result = await copyFrontendTestStandardScenarios({
2070
+ workspaceRoot: input.cwd,
2071
+ });
2072
+ return {
2073
+ ok: true,
2074
+ stdout: JSON.stringify(result),
2075
+ stderr: "",
2076
+ failureCategory: "success",
2077
+ durationMs: Date.now() - started,
2078
+ };
2079
+ }
2080
+ catch (error) {
2081
+ return {
2082
+ ok: false,
2083
+ stdout: "",
2084
+ stderr: error instanceof Error ? error.message : String(error),
2085
+ failureCategory: "invalid-output",
2086
+ durationMs: Date.now() - started,
2087
+ };
2088
+ }
2089
+ }
2090
+ async function executeFrontendTestEnvironmentProbe(input) {
2091
+ const started = Date.now();
2092
+ const result = await probeFrontendTestEnvironment({
2093
+ workspaceRoot: input.cwd,
2094
+ });
2095
+ if (result.ok) {
2096
+ return {
2097
+ ok: true,
2098
+ stdout: result.stdout,
2099
+ stderr: "",
2100
+ failureCategory: "success",
2101
+ durationMs: Date.now() - started,
2102
+ };
2103
+ }
2104
+ return {
2105
+ ok: false,
2106
+ stdout: "",
2107
+ stderr: result.stderr,
2108
+ failureCategory: "nonzero-exit",
2109
+ durationMs: Date.now() - started,
2110
+ };
2111
+ }
2064
2112
  async function executeFrontendTestEvidenceValidation(input) {
2065
2113
  const started = Date.now();
2066
2114
  try {
@@ -2339,6 +2387,12 @@ export async function executeDagShellNode(input, meta) {
2339
2387
  };
2340
2388
  }
2341
2389
  }
2390
+ if (shell?.frontendTestStandardScenarios) {
2391
+ return executeFrontendTestStandardScenarios(input);
2392
+ }
2393
+ if (shell?.frontendTestEnvironmentProbe) {
2394
+ return executeFrontendTestEnvironmentProbe(input);
2395
+ }
2342
2396
  if (shell?.frontendBrowserToolPreflight) {
2343
2397
  const started = Date.now();
2344
2398
  try {
@@ -85,7 +85,7 @@ function hasProviderConfig(snapshot) {
85
85
  /**
86
86
  * Discover live Pi SDK / auth / model readiness via ModelRuntime.
87
87
  * Kept inside worker/console (dynamic import) so Worker does not depend on
88
- * executors; contract matches pi-sdk-executor 0.80.10.
88
+ * executors; contract matches pi-sdk-executor 0.83.0.
89
89
  */
90
90
  export async function discoverPiReadinessProbe(options) {
91
91
  const repoRoot = options?.repoRoot ?? process.cwd();
@@ -118,7 +118,7 @@ export async function discoverPiReadinessProbe(options) {
118
118
  modelsAvailable: false,
119
119
  interviewSessionOk: false,
120
120
  details: {
121
- sdkMessage: "Pi SDK incompatible: requires createAgentSession, getAgentDir, and ModelRuntime.create (0.80.10)",
121
+ sdkMessage: "Pi SDK incompatible: requires createAgentSession, getAgentDir, and ModelRuntime.create (0.83.0)",
122
122
  authMessage: "skipped (sdk incompatible)",
123
123
  modelsMessage: "skipped (sdk incompatible)",
124
124
  sessionMessage: "skipped (sdk incompatible)",
@@ -159,7 +159,7 @@ export async function discoverPiReadinessProbe(options) {
159
159
  modelsAvailable: false,
160
160
  interviewSessionOk: false,
161
161
  details: {
162
- sdkMessage: "Pi SDK 0.80.10 contract available",
162
+ sdkMessage: "Pi SDK 0.83.0 contract available",
163
163
  authMessage: `ModelRuntime.create failed: ${message}`,
164
164
  modelsMessage: "skipped (runtime init failed)",
165
165
  sessionMessage: "skipped (runtime init failed)",
@@ -224,7 +224,7 @@ export async function discoverPiReadinessProbe(options) {
224
224
  interviewSessionOk,
225
225
  piAvailableModels: availableModels,
226
226
  details: {
227
- sdkMessage: `Pi SDK 0.80.10 contract available (agentDir=${agentDir})`,
227
+ sdkMessage: `Pi SDK 0.83.0 contract available (agentDir=${agentDir})`,
228
228
  authMessage: authReady
229
229
  ? `credentials/providers ready (available=${availableCount}, providerConfig=${providerConfigured})`
230
230
  : "no available models and no stored/configured provider credentials (set Pi auth or provider API keys)",
@@ -76,8 +76,8 @@ export const uiState = {
76
76
  dagNodeExecutionOutputLoading: false,
77
77
  dagNodeExecutionOutputError: null,
78
78
  dagNodeExecutionOutputKey: null,
79
- /** When true,「执行过程」展开 message_start/end 原始协议事件。 */
80
- sessionTimelineShowProtocol: false,
79
+ /** When true,「执行过程」展开 message_start/end 原始协议事件。默认展开,用户可点选隐藏。 */
80
+ sessionTimelineShowProtocol: true,
81
81
  /** Open <details> keys restored across timeline re-renders. */
82
82
  sessionTimelineOpenDetails: null,
83
83
  lastSnapshot: null,
@@ -439,12 +439,25 @@ function ensureOpenDetailsSet() {
439
439
  return uiState.sessionTimelineOpenDetails;
440
440
  }
441
441
 
442
- function bindDetailsToggle(details, detailKey) {
442
+ function closedDetailKey(detailKey) {
443
+ return `${detailKey}::user-closed`;
444
+ }
445
+
446
+ function bindDetailsToggle(details, detailKey, defaultOpen = false) {
443
447
  const openSet = ensureOpenDetailsSet();
444
- if (openSet.has(detailKey)) details.open = true;
448
+ if (defaultOpen) {
449
+ if (!openSet.has(closedDetailKey(detailKey))) details.open = true;
450
+ } else if (openSet.has(detailKey)) {
451
+ details.open = true;
452
+ }
445
453
  details.addEventListener("toggle", () => {
446
- if (details.open) openSet.add(detailKey);
447
- else openSet.delete(detailKey);
454
+ if (details.open) {
455
+ openSet.add(detailKey);
456
+ openSet.delete(closedDetailKey(detailKey));
457
+ } else {
458
+ openSet.delete(detailKey);
459
+ openSet.add(closedDetailKey(detailKey));
460
+ }
448
461
  });
449
462
  }
450
463
 
@@ -599,7 +612,7 @@ function renderProtocolCard(item) {
599
612
  const details = document.createElement("details");
600
613
  details.className = "process-timeline-details";
601
614
  details.dataset.detailKey = item.detailKey;
602
- bindDetailsToggle(details, item.detailKey);
615
+ bindDetailsToggle(details, item.detailKey, true);
603
616
  const summary = document.createElement("summary");
604
617
  summary.className = "process-timeline-summary";
605
618
  summary.textContent = `消息收发 · ${item.protocolCount} 条协议事件`;
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { z } from "zod";
6
6
  import YAML from "yaml";
7
7
  import { resolveBackendTestLayout, } from "./backend-test-layout.js";
8
- import { assessScenarioPartitionCoverage, parseScenarioPartitions, } from "./backend-test-scenario-partitions.js";
8
+ import { applyOpenApiPartitionDomainPolicy, assessScenarioPartitionCoverage, parseScenarioPartitions, } from "./backend-test-scenario-partitions.js";
9
9
  import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
10
10
  const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
11
11
  const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
@@ -486,6 +486,55 @@ export async function extractBackendTestOpenApiRules(input) {
486
486
  }
487
487
  return rules.sort((left, right) => left.ruleKey.localeCompare(right.ruleKey));
488
488
  }
489
+ export async function extractBackendTestOpenApiPartitionAxes(input) {
490
+ const axes = [];
491
+ const seen = new Set();
492
+ for (const referencePath of input.sourceBinding.referencePaths) {
493
+ const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
494
+ let document;
495
+ try {
496
+ const parsed = YAML.parse(await readFile(physicalPath, "utf8"));
497
+ if (!isRecord(parsed) || !isRecord(parsed.paths))
498
+ continue;
499
+ document = parsed;
500
+ }
501
+ catch {
502
+ continue;
503
+ }
504
+ for (const [apiPath, pathItem] of Object.entries(document.paths)) {
505
+ if (!isRecord(pathItem))
506
+ continue;
507
+ const operation = pathItem.get;
508
+ if (!isRecord(operation))
509
+ continue;
510
+ const parameterValues = [
511
+ ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []),
512
+ ...(Array.isArray(operation.parameters) ? operation.parameters : []),
513
+ ];
514
+ for (const rawParameter of parameterValues) {
515
+ const parameter = resolveOpenApiSchema(document, rawParameter);
516
+ if (!parameter || typeof parameter.name !== "string")
517
+ continue;
518
+ const location = parameter.in === "path" ? "path" : parameter.in === "query" ? "query" : undefined;
519
+ if (!location)
520
+ continue;
521
+ const property = resolveOpenApiSchema(document, parameter.schema);
522
+ const enumValues = property && Array.isArray(property.enum) ? property.enum.map(String) : null;
523
+ const identity = `GET ${apiPath}|${location}|${parameter.name}`;
524
+ if (seen.has(identity))
525
+ continue;
526
+ seen.add(identity);
527
+ axes.push({
528
+ operation: `GET ${apiPath}`,
529
+ name: parameter.name,
530
+ in: location,
531
+ enumValues,
532
+ });
533
+ }
534
+ }
535
+ }
536
+ return axes;
537
+ }
489
538
  function orderedUnique(values) {
490
539
  const result = [];
491
540
  const seen = new Set();
@@ -985,8 +1034,11 @@ export async function analyzeBackendTestCaseCoverage(input) {
985
1034
  // Plan C: deterministic query/filter axis partitions (fail-closed on missing slots).
986
1035
  const parsedPartitions = parseScenarioPartitions(readme);
987
1036
  findings.push(...parsedPartitions.issues);
1037
+ const openApiPartitionAxes = await extractBackendTestOpenApiPartitionAxes(input);
1038
+ const partitionedBySource = applyOpenApiPartitionDomainPolicy(parsedPartitions.partitions, openApiPartitionAxes);
1039
+ findings.push(...partitionedBySource.issues);
988
1040
  const declaredPointUniverse = new Set(cases.flatMap((item) => item.testPoints));
989
- const partitionAssessment = assessScenarioPartitionCoverage(parsedPartitions.partitions, declaredPointUniverse);
1041
+ const partitionAssessment = assessScenarioPartitionCoverage(partitionedBySource.partitions, declaredPointUniverse);
990
1042
  findings.push(...partitionAssessment.findings);
991
1043
  const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
992
1044
  const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);