@tea-agent/loop-agent 0.25.1 → 0.25.3
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/AGENTS.md +1 -0
- package/CHANGELOG.md +38 -0
- package/dist/cli/command-definitions.js +1 -1
- package/dist/cli/program.js +2 -1
- package/dist/commands/client-recovery.js +657 -0
- package/dist/commands/init.js +80 -3
- package/dist/executors/shell-executor.js +4 -2
- package/dist/workflows/dag/backend-test-markdown-workflow.js +142 -11
- package/dist/workflows/dag/backend-test-result-contract.js +46 -15
- package/docs/architecture/runtime-boundaries.md +13 -0
- package/docs/init-surface.manifest.json +6 -2
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +2 -1
package/dist/commands/init.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { access, copyFile, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { copyDir } from "../shared/copy-dir.js";
|
|
7
8
|
import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
|
|
8
9
|
import { loadHarnessManifest } from "../governance/harness.js";
|
|
10
|
+
import { OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, applyPiRetryMerge, buildOpenCodeTransientRetryPluginSource, inspectPiRetryConfig, parseClientRecoveryMode, runClientRecovery, } from "./client-recovery.js";
|
|
9
11
|
const MANAGED_BLOCK_START = "<!-- LOOP_AGENT_INIT_START -->";
|
|
10
12
|
const MANAGED_BLOCK_END = "<!-- LOOP_AGENT_INIT_END -->";
|
|
11
13
|
const GITIGNORE_BLOCK_START = "# LOOP_AGENT_INIT_START";
|
|
@@ -871,6 +873,9 @@ function buildManagedAgentsBlock(input) {
|
|
|
871
873
|
"- 用户明确提出后端测试、接口/API 测试、pytest,或语境明确为后端的自动化测试时,必须把 `.harness/tasks/<task-id>/task.json` 的 `taskKind` 设置为 `\"backend-test\"`,不得保留默认 `standard`。",
|
|
872
874
|
"- `backend-test` 是 `taskKind`,不是 `--profile` 的可选值;运行 `dag run-task` 时继续使用 `--profile auto`。",
|
|
873
875
|
"- 仅出现“自动化测试”且无法判断前后端时,先阅读任务源与目标项目技术栈再决定,禁止无条件路由到 `backend-test`。",
|
|
876
|
+
"- 用户提示词明确是前端实现需求(例如前端页面、UI、组件或交互开发)时,必须把 `.harness/tasks/<task-id>/task.json` 的 `taskKind` 设置为 `\"frontend-implementation\"`,不得保留默认 `standard`。",
|
|
877
|
+
"- `frontend-implementation` 是 `taskKind`,不是 `--profile` 的可选值;运行 `dag run-task` 时继续使用 `--profile auto`,也可按需显式选择 `minimal` / `standard` / `reviewed` / `supervised`,不要把业务模板名当作 profile。",
|
|
878
|
+
"- 前端自动化测试(浏览器/UI 自动化、Playwright、E2E)继续使用 `taskKind: \"frontend-test\"`,不得设置为 `frontend-implementation`。",
|
|
874
879
|
"",
|
|
875
880
|
"### 运行看板(只读)",
|
|
876
881
|
"",
|
|
@@ -1275,6 +1280,7 @@ function inferInitSurfaceMode(relativePath) {
|
|
|
1275
1280
|
return "managed-block";
|
|
1276
1281
|
}
|
|
1277
1282
|
if (relativePath === "harness.json" ||
|
|
1283
|
+
relativePath === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH ||
|
|
1278
1284
|
relativePath.startsWith("scripts/") ||
|
|
1279
1285
|
relativePath.startsWith(".harness/prompts/") ||
|
|
1280
1286
|
relativePath.startsWith("docs/README.md") ||
|
|
@@ -1321,6 +1327,9 @@ async function buildDesiredSurfaceContent(input) {
|
|
|
1321
1327
|
}), null, 2)}\n`,
|
|
1322
1328
|
};
|
|
1323
1329
|
}
|
|
1330
|
+
if (manifestPath === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH) {
|
|
1331
|
+
return { content: buildOpenCodeTransientRetryPluginSource() };
|
|
1332
|
+
}
|
|
1324
1333
|
if (manifestPath.startsWith("scripts/")) {
|
|
1325
1334
|
const scripts = buildInitScriptFiles(input.governanceRoot);
|
|
1326
1335
|
return { content: scripts[manifestPath] };
|
|
@@ -2443,9 +2452,30 @@ export async function initializeLoopAgentProject(options) {
|
|
|
2443
2452
|
}
|
|
2444
2453
|
await ensureHarnessDirs(repoRoot, written);
|
|
2445
2454
|
await writeCompatPrompts({ repoRoot, merge, written, skipped });
|
|
2455
|
+
const clientRecoveryMode = options.clientRecovery ?? "auto";
|
|
2456
|
+
const clientRecovery = clientRecoveryMode === "off"
|
|
2457
|
+
? undefined
|
|
2458
|
+
: await runClientRecovery({
|
|
2459
|
+
repoRoot,
|
|
2460
|
+
mode: clientRecoveryMode,
|
|
2461
|
+
});
|
|
2462
|
+
if (clientRecovery?.plugin.written) {
|
|
2463
|
+
written.push(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH);
|
|
2464
|
+
}
|
|
2465
|
+
else if (clientRecoveryMode === "off") {
|
|
2466
|
+
skipped.push(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH);
|
|
2467
|
+
}
|
|
2446
2468
|
await writeInitSurfaceState({ repoRoot, projectName, governanceRoot, stateKind: "recorded" });
|
|
2447
2469
|
written.push(INIT_SURFACE_STATE_PATH);
|
|
2448
|
-
return {
|
|
2470
|
+
return {
|
|
2471
|
+
repoRoot,
|
|
2472
|
+
projectName,
|
|
2473
|
+
governanceRoot,
|
|
2474
|
+
profile,
|
|
2475
|
+
written,
|
|
2476
|
+
skipped,
|
|
2477
|
+
clientRecovery,
|
|
2478
|
+
};
|
|
2449
2479
|
}
|
|
2450
2480
|
function actionForMissing(pathName, state) {
|
|
2451
2481
|
if (state.mode === "state")
|
|
@@ -2642,12 +2672,19 @@ export async function checkInitUpdate(input) {
|
|
|
2642
2672
|
},
|
|
2643
2673
|
};
|
|
2644
2674
|
const recommendedNext = recommendedNextFor(partial);
|
|
2675
|
+
// Pi user config is never part of project surface hash; only report semantics.
|
|
2676
|
+
const piInspection = await inspectPiRetryConfig({
|
|
2677
|
+
homeDir: input.homeDir,
|
|
2678
|
+
});
|
|
2645
2679
|
return {
|
|
2646
2680
|
...partial,
|
|
2647
2681
|
ok: deterministicActions.length === 0 &&
|
|
2648
2682
|
modelMergeTasks.length === 0 &&
|
|
2649
2683
|
humanDecisions.length === 0,
|
|
2650
2684
|
recommendedNext,
|
|
2685
|
+
clientRecovery: {
|
|
2686
|
+
pi: piInspection,
|
|
2687
|
+
},
|
|
2651
2688
|
};
|
|
2652
2689
|
}
|
|
2653
2690
|
async function applySafeAction(input) {
|
|
@@ -2811,6 +2848,7 @@ export async function applyInitUpdate(input) {
|
|
|
2811
2848
|
});
|
|
2812
2849
|
const applied = [];
|
|
2813
2850
|
const skipped = [];
|
|
2851
|
+
const clientRecoveryMode = input.clientRecovery ?? "auto";
|
|
2814
2852
|
if (input.bootstrapSurface) {
|
|
2815
2853
|
await writeInitSurfaceState({ repoRoot, projectName, governanceRoot, stateKind: "inferred-baseline" });
|
|
2816
2854
|
applied.push({
|
|
@@ -2824,7 +2862,13 @@ export async function applyInitUpdate(input) {
|
|
|
2824
2862
|
// Preserve source strength: recorded stays recorded so unchanged owned files
|
|
2825
2863
|
// remain deterministic refresh candidates; bootstrap/inferred stays inferred.
|
|
2826
2864
|
const preservedStateKind = existingSurface?.stateKind === "recorded" ? "recorded" : "inferred-baseline";
|
|
2827
|
-
const report = await checkInitUpdate({
|
|
2865
|
+
const report = await checkInitUpdate({
|
|
2866
|
+
repoRoot,
|
|
2867
|
+
projectName,
|
|
2868
|
+
governanceRoot,
|
|
2869
|
+
clientRecovery: clientRecoveryMode,
|
|
2870
|
+
homeDir: input.homeDir,
|
|
2871
|
+
});
|
|
2828
2872
|
for (const action of report.deterministicActions) {
|
|
2829
2873
|
if (action.type === "bootstrap-surface") {
|
|
2830
2874
|
skipped.push(action);
|
|
@@ -2846,12 +2890,36 @@ export async function applyInitUpdate(input) {
|
|
|
2846
2890
|
preserveOwnershipFrom: existingSurface,
|
|
2847
2891
|
});
|
|
2848
2892
|
}
|
|
2893
|
+
// Pi user config is only mutated with explicit --client-recovery=user.
|
|
2894
|
+
// Do not reinstall the project plugin here — that would bypass ownership and
|
|
2895
|
+
// clobber user-modified plugins; plugin updates stay on deterministic actions.
|
|
2896
|
+
if (clientRecoveryMode === "user") {
|
|
2897
|
+
await applyPiRetryMerge({
|
|
2898
|
+
homeDir: input.homeDir ?? os.homedir(),
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2849
2901
|
return {
|
|
2850
2902
|
applied,
|
|
2851
2903
|
skipped,
|
|
2852
|
-
report: await checkInitUpdate({
|
|
2904
|
+
report: await checkInitUpdate({
|
|
2905
|
+
repoRoot,
|
|
2906
|
+
projectName,
|
|
2907
|
+
governanceRoot,
|
|
2908
|
+
clientRecovery: clientRecoveryMode,
|
|
2909
|
+
homeDir: input.homeDir,
|
|
2910
|
+
}),
|
|
2853
2911
|
};
|
|
2854
2912
|
}
|
|
2913
|
+
function formatClientRecoverySummary(report) {
|
|
2914
|
+
if (!report.clientRecovery?.pi)
|
|
2915
|
+
return [];
|
|
2916
|
+
const pi = report.clientRecovery.pi;
|
|
2917
|
+
return [
|
|
2918
|
+
`clientRecovery.pi.reason: ${pi.reason}`,
|
|
2919
|
+
`clientRecovery.pi.action: ${pi.action}`,
|
|
2920
|
+
`clientRecovery.pi.path: ${pi.path}`,
|
|
2921
|
+
];
|
|
2922
|
+
}
|
|
2855
2923
|
function formatCheckUpdateText(report) {
|
|
2856
2924
|
return [
|
|
2857
2925
|
`loop-agent init update check for ${report.repoRoot}`,
|
|
@@ -2860,17 +2928,20 @@ function formatCheckUpdateText(report) {
|
|
|
2860
2928
|
`deterministicActions: ${report.deterministicActions.length}`,
|
|
2861
2929
|
`modelMergeTasks: ${report.modelMergeTasks.length}`,
|
|
2862
2930
|
`humanDecisions: ${report.humanDecisions.length}`,
|
|
2931
|
+
...formatClientRecoverySummary(report),
|
|
2863
2932
|
"recommendedNext:",
|
|
2864
2933
|
...report.recommendedNext.map((item) => `- ${item}`),
|
|
2865
2934
|
].join("\n");
|
|
2866
2935
|
}
|
|
2867
2936
|
function formatCheckUpdateMarkdown(report) {
|
|
2937
|
+
const piLines = formatClientRecoverySummary(report).map((line) => `- ${line}`);
|
|
2868
2938
|
const lines = [
|
|
2869
2939
|
"# loop-agent init check-update",
|
|
2870
2940
|
"",
|
|
2871
2941
|
`- repoRoot: \`${report.repoRoot}\``,
|
|
2872
2942
|
`- controllerVersion: \`${report.controllerVersion}\``,
|
|
2873
2943
|
`- surfaceState: \`${report.surfaceState}\``,
|
|
2944
|
+
...(piLines.length > 0 ? ["", "## Client Recovery (Pi user config, read-only)", "", ...piLines] : []),
|
|
2874
2945
|
"",
|
|
2875
2946
|
"## Deterministic Actions",
|
|
2876
2947
|
"",
|
|
@@ -2971,6 +3042,7 @@ function parseInitArgs(repoRoot, args) {
|
|
|
2971
3042
|
let merge = true;
|
|
2972
3043
|
let provider;
|
|
2973
3044
|
let model;
|
|
3045
|
+
let clientRecovery = "auto";
|
|
2974
3046
|
let json = false;
|
|
2975
3047
|
let markdown = false;
|
|
2976
3048
|
let bootstrapSurface = false;
|
|
@@ -3005,6 +3077,10 @@ function parseInitArgs(repoRoot, args) {
|
|
|
3005
3077
|
model = args[++i];
|
|
3006
3078
|
else if (arg.startsWith("--model="))
|
|
3007
3079
|
model = arg.slice("--model=".length);
|
|
3080
|
+
else if (arg === "--client-recovery")
|
|
3081
|
+
clientRecovery = parseClientRecoveryMode(args[++i]);
|
|
3082
|
+
else if (arg.startsWith("--client-recovery="))
|
|
3083
|
+
clientRecovery = parseClientRecoveryMode(arg.slice("--client-recovery=".length));
|
|
3008
3084
|
else if (arg === "--json")
|
|
3009
3085
|
json = true;
|
|
3010
3086
|
else if (arg === "--markdown")
|
|
@@ -3028,6 +3104,7 @@ function parseInitArgs(repoRoot, args) {
|
|
|
3028
3104
|
merge,
|
|
3029
3105
|
provider,
|
|
3030
3106
|
model,
|
|
3107
|
+
clientRecovery,
|
|
3031
3108
|
subcommand,
|
|
3032
3109
|
json,
|
|
3033
3110
|
markdown,
|
|
@@ -587,11 +587,13 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
587
587
|
throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
|
|
588
588
|
}
|
|
589
589
|
const parsed = parsePytestHtmlReport(pytestHtmlContent);
|
|
590
|
+
// Keep native pytest-html (data-jsonblob) for audit before styled overwrite.
|
|
591
|
+
const nativeHtmlPath = await writeRunReport(meta.runDir, "backend-test-pytest-native.html", pytestHtmlContent);
|
|
590
592
|
// Bind Result v1 from the native pytest-html report BEFORE overwriting with the
|
|
591
593
|
// styled renderer (which drops the data-jsonblob island).
|
|
592
594
|
const resultArtifact = await materializeBackendTestResultFromPytestHtml({
|
|
593
595
|
runDir: meta.runDir,
|
|
594
|
-
htmlRelativePath: "reports/backend-test.html",
|
|
596
|
+
htmlRelativePath: "reports/backend-test-pytest-native.html",
|
|
595
597
|
htmlContent: pytestHtmlContent,
|
|
596
598
|
pytestExitCode,
|
|
597
599
|
});
|
|
@@ -682,7 +684,7 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
682
684
|
});
|
|
683
685
|
const l5Path = await writeRunReport(meta.runDir, "backend-test-l5-dashboard.html", l5Html);
|
|
684
686
|
const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
|
|
685
|
-
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `l5-dashboard=${l5Path}`, `result=${resultArtifact.path}`, facts);
|
|
687
|
+
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `nativeHtml=${nativeHtmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `l5-dashboard=${l5Path}`, `result=${resultArtifact.path}`, facts);
|
|
686
688
|
}
|
|
687
689
|
else if (pipeline === "contracts") {
|
|
688
690
|
const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
|
|
@@ -774,15 +774,57 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
|
|
|
774
774
|
function junitCaseId(name) {
|
|
775
775
|
return symbolCaseId(name) ?? name.match(CASE_ID)?.[0];
|
|
776
776
|
}
|
|
777
|
+
/** Prefer function-name Case ID; fall back to first BE-* in free text (docstring/log). */
|
|
778
|
+
function extractCaseIdFromText(value) {
|
|
779
|
+
if (!value?.trim())
|
|
780
|
+
return undefined;
|
|
781
|
+
return junitCaseId(value) ?? value.match(CASE_ID)?.[0];
|
|
782
|
+
}
|
|
783
|
+
function resolveReportCaseId(result, catalog) {
|
|
784
|
+
const fromName = extractCaseIdFromText(result.name);
|
|
785
|
+
if (fromName && catalog.has(fromName))
|
|
786
|
+
return fromName;
|
|
787
|
+
if (fromName)
|
|
788
|
+
return fromName;
|
|
789
|
+
// Match catalog by pytest function/method name when Case ID is only in docstring/md.
|
|
790
|
+
const byFunction = [...catalog.values()].find((item) => item.testFunctions.includes(result.name));
|
|
791
|
+
if (byFunction)
|
|
792
|
+
return byFunction.id;
|
|
793
|
+
const fromStdout = extractCaseIdFromText(result.stdout);
|
|
794
|
+
if (fromStdout && catalog.has(fromStdout))
|
|
795
|
+
return fromStdout;
|
|
796
|
+
if (fromStdout)
|
|
797
|
+
return fromStdout;
|
|
798
|
+
const fromDetails = extractCaseIdFromText(result.details);
|
|
799
|
+
if (fromDetails && catalog.has(fromDetails))
|
|
800
|
+
return fromDetails;
|
|
801
|
+
return "未关联";
|
|
802
|
+
}
|
|
777
803
|
function humanStatus(status) {
|
|
778
804
|
return status === "passed" ? "通过" : status === "failure" ? "失败" : status === "error" ? "错误" : "跳过";
|
|
779
805
|
}
|
|
780
806
|
function formatDuration(durationMs) {
|
|
781
807
|
return durationMs === undefined ? "未记录" : `${(durationMs / 1000).toFixed(3)} 秒`;
|
|
782
808
|
}
|
|
783
|
-
function inferredScriptPath(classname) {
|
|
784
|
-
|
|
785
|
-
|
|
809
|
+
function inferredScriptPath(classname, filePath) {
|
|
810
|
+
if (filePath?.trim()) {
|
|
811
|
+
return filePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
812
|
+
}
|
|
813
|
+
const moduleName = classname
|
|
814
|
+
.split(".")
|
|
815
|
+
.filter((part) => part && !/^Test[A-Z_]/.test(part) && part !== "Test")
|
|
816
|
+
.join("/");
|
|
817
|
+
if (!moduleName || moduleName === "unknown")
|
|
818
|
+
return "unknown.py";
|
|
819
|
+
return moduleName.endsWith(".py") ? moduleName : `${moduleName}.py`;
|
|
820
|
+
}
|
|
821
|
+
function resolveReportScriptPath(result, item) {
|
|
822
|
+
if (item?.scriptPath)
|
|
823
|
+
return item.scriptPath;
|
|
824
|
+
if (result.filePath?.trim()) {
|
|
825
|
+
return result.filePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
826
|
+
}
|
|
827
|
+
return inferredScriptPath(result.classname, result.filePath);
|
|
786
828
|
}
|
|
787
829
|
export function redactBackendTestOutput(value) {
|
|
788
830
|
return value
|
|
@@ -815,8 +857,97 @@ function reportOutputLines(value, prefix) {
|
|
|
815
857
|
return redactBackendTestOutput(trimmed.trim()).slice(0, 4000);
|
|
816
858
|
});
|
|
817
859
|
}
|
|
860
|
+
/**
|
|
861
|
+
* Normalize alternate HTTP log dialects into HTTP_REQUEST / HTTP_RESPONSE lines.
|
|
862
|
+
* Supports common project helpers that emit [REQ]/[RESP] block style instead of
|
|
863
|
+
* the contract prefixes (keeps original HTTP_* lines unchanged).
|
|
864
|
+
*/
|
|
865
|
+
export function normalizeBackendTestHttpLogText(value) {
|
|
866
|
+
if (!value.trim())
|
|
867
|
+
return value;
|
|
868
|
+
const lines = value.split(/\r?\n/);
|
|
869
|
+
const out = [];
|
|
870
|
+
let i = 0;
|
|
871
|
+
while (i < lines.length) {
|
|
872
|
+
const raw = lines[i];
|
|
873
|
+
const line = raw.trim();
|
|
874
|
+
// Already-contract lines: pass through.
|
|
875
|
+
if (/^(HTTP_REQUEST|HTTP REQUEST|HTTP_RESPONSE|HTTP RESPONSE)\b/i.test(line)) {
|
|
876
|
+
out.push(raw);
|
|
877
|
+
i += 1;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
// [REQ] METHOD path OR [REQ] METHOD url
|
|
881
|
+
const reqHead = line.match(/^\[REQ\]\s+([A-Z]+)\s+(\S+)\s*$/i);
|
|
882
|
+
if (reqHead) {
|
|
883
|
+
const method = reqHead[1].toUpperCase();
|
|
884
|
+
const url = reqHead[2];
|
|
885
|
+
let body = "";
|
|
886
|
+
let j = i + 1;
|
|
887
|
+
// Optional [REQ body] then JSON/text until separator or next tag.
|
|
888
|
+
if (j < lines.length && /^\[REQ\s*body\]/i.test(lines[j].trim())) {
|
|
889
|
+
j += 1;
|
|
890
|
+
const bodyLines = [];
|
|
891
|
+
while (j < lines.length &&
|
|
892
|
+
!/^[=-]{3,}\s*$/.test(lines[j].trim()) &&
|
|
893
|
+
!/^\[(?:REQ|RESP)/i.test(lines[j].trim()) &&
|
|
894
|
+
!/^(HTTP_REQUEST|HTTP_RESPONSE|HTTP REQUEST|HTTP RESPONSE)\b/i.test(lines[j].trim())) {
|
|
895
|
+
bodyLines.push(lines[j]);
|
|
896
|
+
j += 1;
|
|
897
|
+
}
|
|
898
|
+
body = bodyLines.join("\n").trim();
|
|
899
|
+
}
|
|
900
|
+
const payload = { method, url };
|
|
901
|
+
if (body) {
|
|
902
|
+
try {
|
|
903
|
+
payload.json = JSON.parse(body);
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
payload.body = body.slice(0, 4000);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
out.push(`HTTP_REQUEST ${JSON.stringify(payload)}`);
|
|
910
|
+
i = j;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
// [RESP status] 200 OR [RESP] 200
|
|
914
|
+
const respHead = line.match(/^\[RESP(?:\s+status)?\]\s+(\d{3})\b/i);
|
|
915
|
+
if (respHead) {
|
|
916
|
+
const status_code = Number(respHead[1]);
|
|
917
|
+
let body = "";
|
|
918
|
+
let j = i + 1;
|
|
919
|
+
if (j < lines.length && /^\[RESP\s*body\]/i.test(lines[j].trim())) {
|
|
920
|
+
j += 1;
|
|
921
|
+
const bodyLines = [];
|
|
922
|
+
while (j < lines.length &&
|
|
923
|
+
!/^[=-]{3,}\s*$/.test(lines[j].trim()) &&
|
|
924
|
+
!/^\[(?:REQ|RESP)/i.test(lines[j].trim()) &&
|
|
925
|
+
!/^(HTTP_REQUEST|HTTP_RESPONSE|HTTP REQUEST|HTTP RESPONSE)\b/i.test(lines[j].trim())) {
|
|
926
|
+
bodyLines.push(lines[j]);
|
|
927
|
+
j += 1;
|
|
928
|
+
}
|
|
929
|
+
body = bodyLines.join("\n").trim();
|
|
930
|
+
}
|
|
931
|
+
const payload = { status_code };
|
|
932
|
+
if (body) {
|
|
933
|
+
try {
|
|
934
|
+
payload.result = JSON.parse(body);
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
payload.result = body.slice(0, 4000);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
out.push(`HTTP_RESPONSE ${JSON.stringify(payload)}`);
|
|
941
|
+
i = j;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
out.push(raw);
|
|
945
|
+
i += 1;
|
|
946
|
+
}
|
|
947
|
+
return out.join("\n");
|
|
948
|
+
}
|
|
818
949
|
function requestResponseSummary(result) {
|
|
819
|
-
const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
950
|
+
const combined = normalizeBackendTestHttpLogText([result.stdout, result.stderr].filter(Boolean).join("\n"));
|
|
820
951
|
return {
|
|
821
952
|
requests: reportOutputLines(combined, "HTTP_REQUEST"),
|
|
822
953
|
responses: reportOutputLines(combined, "HTTP_RESPONSE"),
|
|
@@ -1053,8 +1184,8 @@ export function renderBackendTestHtml(input) {
|
|
|
1053
1184
|
const failures = input.parsed.cases.filter((result) => result.status !== "passed");
|
|
1054
1185
|
const headColor = failed ? { fg: "#b42318", bg: "linear-gradient(135deg,#fef3f2 0,#fee4e2 100%)", border: "#fda29b", icon: "✗" } : { fg: "#067647", bg: "linear-gradient(135deg,#ecfdf3 0,#d1fadf 100%)", border: "#abefc6", icon: "✓" };
|
|
1055
1186
|
const caseCards = input.parsed.cases.map((result) => {
|
|
1056
|
-
const caseId =
|
|
1057
|
-
const item = catalog.get(caseId);
|
|
1187
|
+
const caseId = resolveReportCaseId(result, catalog);
|
|
1188
|
+
const item = caseId === "未关联" ? undefined : catalog.get(caseId);
|
|
1058
1189
|
const io = requestResponseSummary(result);
|
|
1059
1190
|
const calls = pairHttpCalls(io.requests, io.responses);
|
|
1060
1191
|
const statusColors = statusColor(result.status);
|
|
@@ -1064,7 +1195,7 @@ export function renderBackendTestHtml(input) {
|
|
|
1064
1195
|
const failureBlock = result.status === "passed"
|
|
1065
1196
|
? ""
|
|
1066
1197
|
: `<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:#fef3f2;border:1px solid #fda29b;color:#b42318;font-size:0.84rem"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? `<details style="margin-top:6px"><summary style="cursor:pointer;color:#b42318;font-size:0.78rem">失败详情</summary><pre style="white-space:pre-wrap;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.78rem;margin-top:6px;border:1px solid #eceef2;line-height:1.6">${escapeHtml(result.details)}</pre></details>` : ""}</div>`;
|
|
1067
|
-
const scriptPath =
|
|
1198
|
+
const scriptPath = resolveReportScriptPath(result, item);
|
|
1068
1199
|
return `<details style="background:#fff;border:1px solid #e6eaf0;border-radius:12px;margin-top:10px"><summary style="display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;list-style:none"><span style="background:#e8edf5;color:#2b3a55;padding:2px 9px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.76rem;font-weight:600;white-space:nowrap">${escapeHtml(caseId)}</span><div style="flex:1;min-width:0"><div style="font-weight:600;color:#172033;font-size:0.92rem">${escapeHtml(item?.title ?? result.name)}</div><code style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.74rem;color:#98a2b3">${escapeHtml(result.name)}</code></div><span style="color:#98a2b3;font-size:0.8rem;white-space:nowrap">${formatDuration(result.durationMs)}</span><span style="display:inline-block;padding:2px 10px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${statusColors.fg};background:${statusColors.bg}">${humanStatus(result.status)}</span><span style="color:#aab2bf;font-size:0.8rem">▾</span></summary><div style="padding:4px 16px 14px 16px;border-top:1px solid #f0f3f7"><div style="margin-top:10px"><div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px">自动化脚本</div><code style="background:#eef2f7;color:#344054;padding:1px 6px;border-radius:5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.8rem">${escapeHtml(scriptPath)}</code></div>${item?.scenario ? `<div style="margin-top:12px"><div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px">验收场景</div><div style="color:#475467;font-size:0.86rem;line-height:1.75">${escapeHtml(item.scenario)}</div></div>` : ""}${logSection}${failureBlock}</div></details>`;
|
|
1069
1200
|
}).join("");
|
|
1070
1201
|
const metricCard = (label, value, valueColor = "#172033") => `<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">${escapeHtml(label)}</span><div style="font-size:22px;font-weight:700;color:${valueColor}">${value}</div></div>`;
|
|
@@ -1073,7 +1204,7 @@ export function renderBackendTestHtml(input) {
|
|
|
1073
1204
|
const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation)}${renderQualityCard("Markdown → pytest 追溯", traceability)}</div></div>`;
|
|
1074
1205
|
const failureOverview = failures.length > 0
|
|
1075
1206
|
? `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">失败概览</div><div style="display:grid;gap:10px;margin-top:8px">${failures.map((result) => {
|
|
1076
|
-
const caseId =
|
|
1207
|
+
const caseId = resolveReportCaseId(result, catalog);
|
|
1077
1208
|
const c = statusColor(result.status);
|
|
1078
1209
|
return `<div style="border-left:4px solid ${c.fg};background:${c.bg};padding:10px 12px;border-radius:8px"><div style="font-weight:600;color:#172033;font-size:0.88rem">${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</div><div style="color:${c.fg};font-size:0.82rem;margin-top:2px">${escapeHtml(result.message || humanStatus(result.status))}</div></div>`;
|
|
1079
1210
|
}).join("")}</div></div>`
|
|
@@ -1342,9 +1473,9 @@ export function renderBackendTestFacts(input) {
|
|
|
1342
1473
|
"| 用例编号 | 用例名称 | 自动化脚本 | 测试函数 | 结果 | 耗时 | 失败原因 |",
|
|
1343
1474
|
"|---|---|---|---|---|---:|---|",
|
|
1344
1475
|
...input.parsed.cases.map((result) => {
|
|
1345
|
-
const caseId =
|
|
1346
|
-
const item = catalog.get(caseId);
|
|
1347
|
-
const script =
|
|
1476
|
+
const caseId = resolveReportCaseId(result, catalog);
|
|
1477
|
+
const item = caseId === "未关联" ? undefined : catalog.get(caseId);
|
|
1478
|
+
const script = resolveReportScriptPath(result, item);
|
|
1348
1479
|
return `| ${caseId} | ${item?.title ?? result.name} | \`${script}\` | \`${result.name}\` | ${humanStatus(result.status)} | ${formatDuration(result.durationMs)} | ${(result.message ?? "—").replaceAll("|", "\\|")} |`;
|
|
1349
1480
|
}),
|
|
1350
1481
|
"",
|
|
@@ -428,7 +428,8 @@ export function parsePytestHtmlReport(html) {
|
|
|
428
428
|
continue;
|
|
429
429
|
const rawResult = (record.result ?? "").toLowerCase();
|
|
430
430
|
const testId = record.testId ?? nodeId;
|
|
431
|
-
const { classname, name } = splitPytestNodeId(testId);
|
|
431
|
+
const { classname, name, filePath } = splitPytestNodeId(testId);
|
|
432
|
+
const filePathFields = filePath ? { filePath } : {};
|
|
432
433
|
const durationMs = parseDurationLabelMs(record.duration);
|
|
433
434
|
const capturedLog = record.log ?? "";
|
|
434
435
|
// pytest-html collapses captured stdout/stderr into a single `log` field
|
|
@@ -441,6 +442,8 @@ export function parsePytestHtmlReport(html) {
|
|
|
441
442
|
cases.push({
|
|
442
443
|
classname,
|
|
443
444
|
name,
|
|
445
|
+
...filePathFields,
|
|
446
|
+
...filePathFields,
|
|
444
447
|
durationMs,
|
|
445
448
|
status: "passed",
|
|
446
449
|
...(stdout ? { stdout } : {}),
|
|
@@ -455,6 +458,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
455
458
|
cases.push({
|
|
456
459
|
classname,
|
|
457
460
|
name,
|
|
461
|
+
...filePathFields,
|
|
458
462
|
durationMs,
|
|
459
463
|
status: "failure",
|
|
460
464
|
message: summary,
|
|
@@ -471,6 +475,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
471
475
|
cases.push({
|
|
472
476
|
classname,
|
|
473
477
|
name,
|
|
478
|
+
...filePathFields,
|
|
474
479
|
durationMs,
|
|
475
480
|
status: "error",
|
|
476
481
|
message: summary,
|
|
@@ -484,6 +489,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
484
489
|
cases.push({
|
|
485
490
|
classname,
|
|
486
491
|
name,
|
|
492
|
+
...filePathFields,
|
|
487
493
|
durationMs,
|
|
488
494
|
status: "skipped",
|
|
489
495
|
...(stdout ? { stdout } : {}),
|
|
@@ -499,6 +505,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
499
505
|
cases.push({
|
|
500
506
|
classname,
|
|
501
507
|
name,
|
|
508
|
+
...filePathFields,
|
|
502
509
|
durationMs,
|
|
503
510
|
status: "error",
|
|
504
511
|
message: summary,
|
|
@@ -520,20 +527,44 @@ export function parsePytestHtmlReport(html) {
|
|
|
520
527
|
failures: failures.slice(0, MAX_FAILURES),
|
|
521
528
|
};
|
|
522
529
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
530
|
+
/**
|
|
531
|
+
* Split a pytest node id into module classname, test name, and optional file path.
|
|
532
|
+
*
|
|
533
|
+
* Handles:
|
|
534
|
+
* - `testcase/test_x.py::test_name`
|
|
535
|
+
* - `testcase/test_x.py::TestClass::test_name` (class-based; previous code
|
|
536
|
+
* treated `TestClass` as the file segment and later filtered it to unknown.py)
|
|
537
|
+
* - Windows separators and parametrized names `test_name[param]`
|
|
538
|
+
*/
|
|
539
|
+
export function splitPytestNodeId(testId) {
|
|
540
|
+
const normalized = testId.replaceAll("\\", "/").trim();
|
|
541
|
+
if (!normalized) {
|
|
542
|
+
return { classname: "unknown", name: "unknown" };
|
|
543
|
+
}
|
|
544
|
+
const parts = normalized.split("::").filter(Boolean);
|
|
545
|
+
if (parts.length < 2) {
|
|
546
|
+
// Bare id without "::" — keep as name; path unknown.
|
|
547
|
+
return { classname: "unknown", name: normalized };
|
|
548
|
+
}
|
|
549
|
+
let rawName = parts[parts.length - 1];
|
|
550
|
+
// Drop call args if present; keep parametrize brackets in display name strip for id match.
|
|
551
|
+
const paren = rawName.indexOf("(");
|
|
552
|
+
if (paren >= 0)
|
|
553
|
+
rawName = rawName.slice(0, paren);
|
|
554
|
+
const cleanName = rawName.trim() || "unknown";
|
|
555
|
+
// Prefer the first segment that looks like a .py file path.
|
|
556
|
+
const filePart = parts.find((part) => /\.py$/i.test(part)) ??
|
|
557
|
+
(parts[0].includes("/") || parts[0].includes(".") ? parts[0] : undefined);
|
|
558
|
+
const filePath = filePart
|
|
559
|
+
? filePart.replace(/\\/g, "/")
|
|
560
|
+
: undefined;
|
|
561
|
+
const moduleWithoutExt = (filePath ?? "unknown").replace(/\.py$/i, "");
|
|
562
|
+
const classname = moduleWithoutExt.replaceAll("/", ".") || "unknown";
|
|
563
|
+
return {
|
|
564
|
+
classname,
|
|
565
|
+
name: cleanName,
|
|
566
|
+
...(filePath ? { filePath } : {}),
|
|
567
|
+
};
|
|
537
568
|
}
|
|
538
569
|
function splitPytestHtmlLogSections(log) {
|
|
539
570
|
if (!log)
|
|
@@ -190,6 +190,19 @@ bash scripts/check-skill-entry.sh
|
|
|
190
190
|
|
|
191
191
|
Runtime 变更另需 `npm run typecheck` 及对应 targeted Vitest(见 exec plan 各 Phase 验证关口)。
|
|
192
192
|
|
|
193
|
+
## Client session 瞬态恢复边界
|
|
194
|
+
|
|
195
|
+
主会话模型偶尔会返回非标准 502 / `LLMRequestError` / 网络抖动 / 超时响应;OpenCode 可能先解析为 `TypeValidationError` 再落成 `UnknownError`,导致内置 APIError 重试不命中。loop-agent 通过 **项目级 OpenCode 插件补偿** 与 **Pi 用户级配置显式启用** 处理该缺口,不修改 OpenCode/Pi 上游,也不引入外部监督器。
|
|
196
|
+
|
|
197
|
+
| 面 | 路径 / 入口 | 边界 |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| OpenCode 项目插件 | `.opencode/plugins/loop-agent-transient-retry.js`(init surface `generated`) | 直接返回真实 `Hooks.event`,分发 `session.error` / `session.status` / `message.updated`;从 `client.session.status()` 的 session map 判断内置 retry,以 `client.session.promptAsync()` 续接同一 session;只有成功完成的 assistant message 清零连续失败计数。认证/权限/配额/上下文溢出/取消/业务错误走 `plugin-ignore-permanent-error` |
|
|
200
|
+
| Pi 用户配置 | `~/.pi/agent/settings.json` | **不**进入项目 `.harness/init-surface.json` hash;仅 `--client-recovery=user` 可字段级补缺并原子写;`auto`/`project`/`off` 与 `check-update` 默认零写 home;读取时只有 `ENOENT` 视为缺文件,其他 I/O 错误 fail closed |
|
|
201
|
+
| CLI mode | `--client-recovery=auto\|project\|user\|off`(默认 `auto`) | `auto`/`project` 只装项目插件;`user` = 项目插件 + 显式 Pi 合并;`off` 全跳过 |
|
|
202
|
+
| Ownership | recorded sha256 + apply-safe | 插件缺失可补、与 recorded hash 一致可升级;用户改过 → model merge / human decision,禁止静默覆盖 |
|
|
203
|
+
|
|
204
|
+
实现落点:`src/commands/client-recovery.ts`(纯逻辑与生成器)+ `src/commands/init.ts`(编排)。
|
|
205
|
+
|
|
193
206
|
## 演进里程碑
|
|
194
207
|
|
|
195
208
|
| Phase | 边界变化 |
|
|
@@ -164,7 +164,8 @@
|
|
|
164
164
|
"docs/templates/backend-test-analysis.schema.json",
|
|
165
165
|
"docs/templates/backend-test-execution.schema.json",
|
|
166
166
|
"docs/templates/backend-test-result.schema.json",
|
|
167
|
-
"docs/templates/backend-test-case-manifest.schema.json"
|
|
167
|
+
"docs/templates/backend-test-case-manifest.schema.json",
|
|
168
|
+
".opencode/plugins/loop-agent-transient-retry.js"
|
|
168
169
|
],
|
|
169
170
|
"initSurface": {
|
|
170
171
|
"README.md": "managed-block",
|
|
@@ -244,7 +245,8 @@
|
|
|
244
245
|
"docs/templates/backend-test-analysis.schema.json": "copied",
|
|
245
246
|
"docs/templates/backend-test-execution.schema.json": "copied",
|
|
246
247
|
"docs/templates/backend-test-result.schema.json": "copied",
|
|
247
|
-
"docs/templates/backend-test-case-manifest.schema.json": "copied"
|
|
248
|
+
"docs/templates/backend-test-case-manifest.schema.json": "copied",
|
|
249
|
+
".opencode/plugins/loop-agent-transient-retry.js": "generated"
|
|
248
250
|
},
|
|
249
251
|
"packageExcluded": [
|
|
250
252
|
"docs/progress/20*.md",
|
|
@@ -264,6 +266,7 @@
|
|
|
264
266
|
"harness.json",
|
|
265
267
|
"package.json",
|
|
266
268
|
"src/commands/init.ts",
|
|
269
|
+
"src/commands/client-recovery.ts",
|
|
267
270
|
"src/cli.ts",
|
|
268
271
|
"src/cli/update/init-surface-notifier.ts",
|
|
269
272
|
"src/cli/update/policy.ts",
|
|
@@ -287,6 +290,7 @@
|
|
|
287
290
|
"description": "Changes that may alter target-project initialization behavior, default DAG role skills, skill resolution, or package/init contracts.",
|
|
288
291
|
"patterns": [
|
|
289
292
|
"src/commands/init.ts",
|
|
293
|
+
"src/commands/client-recovery.ts",
|
|
290
294
|
"src/cli.ts",
|
|
291
295
|
"src/cli/update/init-surface-notifier.ts",
|
|
292
296
|
"src/cli/update/policy.ts",
|
package/package.json
CHANGED
|
@@ -56,9 +56,10 @@ loop-agent run-dag --dag .harness/tasks/<task-id>/dag.json --cwd <repo-root>
|
|
|
56
56
|
6. 主会话不绕过 CLI 直接写业务代码;失败只走 doctor/reconcile/human gate/重跑。
|
|
57
57
|
7. DAG `pi` executor read-only unless `toolProfile: "write"`;completed run facts read-only;不得从 read-only DAG/sidecar 写 root `artifacts/`。
|
|
58
58
|
8. No hidden state in chat only;verify before completion.
|
|
59
|
+
9. Client recovery:`init --client-recovery=auto|project|user|off`;只有 `user` 写 Pi settings;check/update 遵守 ownership。
|
|
59
60
|
|
|
60
61
|
## References
|
|
61
62
|
|
|
62
63
|
Required(按需 inline):`references/harness-policy.md`、`references/hybrid-dag.md`、`references/verification-and-failure-handling.md`
|
|
63
64
|
|
|
64
|
-
Optional
|
|
65
|
+
Optional 索引见 `references/README.md`;常用:`command-reference.md`、`long-running-loop.md`、`orchestrator-and-interventions.md`、`docs-converge.md`。
|