@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.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -0
- package/dist/build-stamp.json +2 -2
- package/dist/executors/pi-executor.js +51 -0
- package/dist/executors/pi-sdk-executor.js +108 -57
- package/dist/executors/shell-executor.js +55 -1
- package/dist/worker/console/pi-readiness.js +4 -4
- package/dist/worker/observe/static/state.js +2 -2
- package/dist/worker/observe/static/views/session-timeline.js +18 -5
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +54 -2
- package/dist/workflows/dag/backend-test-scenario-partitions.js +73 -1
- package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
- package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
- package/dist/workflows/dag/frontend-test-markdown.js +61 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
- package/dist/workflows/dag/init-hybrid.js +10 -18
- package/dist/workflows/dag/rerun-plan.js +22 -3
- package/dist/workflows/dag/types.js +4 -0
- package/dist/workflows/dag/validate.js +2 -0
- package/docs/operations/local-development-environment.md +1 -5
- package/docs/skills/vetted-skill-registry.md +14 -0
- package/docs/templates/README.md +1 -1
- package/docs/templates/agent-dag.schema.json +31 -0
- package/docs/templates/backend-test-dag.json +2 -2
- package/docs/templates/frontend-test-dag.json +8 -10
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/package.json +8 -4
- package/skills/codebase-scout/SKILL.md +1 -1
- package/skills/improve-codebase-architecture/SKILL.md +81 -0
- package/skills/improve-codebase-architecture/deepening.md +37 -0
- package/skills/improve-codebase-architecture/html-report.md +123 -0
- package/skills/improve-codebase-architecture/interface-design.md +44 -0
- 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
package/dist/build-stamp.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
572
|
+
});
|
|
573
|
+
};
|
|
570
574
|
const armStallWatchdog = () => {
|
|
571
|
-
if (
|
|
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
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
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 (
|
|
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)
|
|
447
|
-
|
|
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(
|
|
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/);
|