feat: add caserun prompt preview

This commit is contained in:
Codex Agent
2026-06-08 13:27:16 +08:00
parent 1b87c3d672
commit 34ee947285
2 changed files with 124 additions and 30 deletions
+53 -7
View File
@@ -103,6 +103,49 @@ test("case prepare uses the new registry by default and refuses the removed hwpo
}
});
test("case prompt previews the real assembled prompt in Chinese without starting a run", async () => {
const root = await mkdtempCaseRoot();
const caseRepo = path.join(root, "hwlab-case-registry");
const cwd = path.join(root, "hwlab");
const caseDir = path.join(caseRepo, "cases", "d601-f103-v2-prompt-preview");
try {
await mkdir(path.join(caseRepo, ".git"), { recursive: true });
await writeFile(path.join(caseRepo, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
await mkdir(caseDir, { recursive: true });
await mkdir(cwd, { recursive: true });
await writeFile(path.join(caseDir, "case.json"), JSON.stringify({
contractVersion: "hwpod-case-v1",
caseId: "d601-f103-v2-prompt-preview",
title: "D601-F103-V2 中文 Prompt 预览",
hwpodSpec: "hwpod-spec.yaml",
subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline" },
agentTask: { workspace: "subjectWorktree", prompt: "请在隔离主体工作区中读取 main.c,并只回报当前文件中与串口初始化相关的函数名。", constraints: ["不要修改源码。"] }
}, null, 2), "utf8");
await writeFile(path.join(caseDir, "hwpod-spec.yaml"), hwpodSpecText(), "utf8");
const result = await runHwlabCli(["case", "prompt", "d601-f103-v2-prompt-preview", "--case-repo", caseRepo, "--run-id", "run-prompt-preview"], { cwd, now: () => "2026-06-08T00:00:00.000Z" });
assert.equal(result.exitCode, 0);
assert.equal(result.payload.action, "case.prompt");
assert.equal(result.payload.previewOnly, true);
assert.equal(result.payload.serviceRuntime, false);
assert.match(result.payload.prompt, /# HWPOD CaseRun /u);
assert.match(result.payload.prompt, /## /u);
assert.match(result.payload.prompt, /## HWPOD /u);
assert.match(result.payload.prompt, /## /u);
assert.match(result.payload.prompt, /## /u);
assert.match(result.payload.prompt, /## /u);
assert.match(result.payload.prompt, /使/u);
assert.match(result.payload.prompt, /: /u);
assert.match(result.payload.prompt, / main\.c/u);
assert.match(result.payload.prompt, /hwpod-ctl spec validate --hwpod-id d601-f103-v2 --workspace-path/u);
assert.doesNotMatch(result.payload.prompt, /Runtime Assembly|Standard smoke sequence|verificationMode: compile-only build check|## Task|## Constraints|## Flow/u);
await assert.rejects(stat(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-prompt-preview")));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("case run extracts Keil async job ids from hwpod-node output", () => {
const jobId = extractKeilJobId({ body: { results: [{ stdout: JSON.stringify({ ok: true, job_id: "20260605_203835_798515c0" }) }] } });
assert.equal(jobId, "20260605_203835_798515c0");
@@ -284,7 +327,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
title: "D601-F103-V2 Keil compile-only CaseRun",
hwpodSpec: "hwpod-spec.yaml",
subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline" },
agentTask: { workspace: "subjectWorktree", prompt: "Make a tiny source change through HWPOD.", timeoutMs: 234000, pollIntervalMs: 10 },
agentTask: { workspace: "subjectWorktree", prompt: "请通过 HWPOD 做一个很小的源码修改。", timeoutMs: 234000, pollIntervalMs: 10 },
runtime: { apiUrl: "http://api.test", webUrl: "http://web.test" }
}, null, 2), "utf8");
await writeFile(path.join(caseDir, "hwpod-spec.yaml"), hwpodSpecText(), "utf8");
@@ -462,10 +505,13 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
assert.doesNotMatch(summaryMarkdown, /downloadSkipped/u);
const prompt = await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "agent-prompt.md"), "utf8");
assert.match(prompt, /without auto-grading/u);
assert.match(prompt, /Runtime Assembly/u);
assert.match(prompt, //u);
assert.match(prompt, //u);
assert.match(prompt, /kind=gitbundle/u);
assert.match(prompt, /verificationMode: compile-only build check/u);
assert.match(prompt, /使/u);
assert.match(prompt, /: /u);
assert.match(prompt, / HWPOD /u);
assert.doesNotMatch(prompt, /Runtime Assembly|verificationMode: compile-only build check|Standard smoke sequence|## Task|## Constraints|## Flow|without auto-grading/u);
assert.doesNotMatch(prompt, /compile-only subject /u);
assert.doesNotMatch(prompt, /Write exactly this YAML/u);
assert.doesNotMatch(prompt, /```yaml/u);
@@ -477,10 +523,10 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
assert.match(prompt, /hwpodWorkspaceArgs: --hwpod-id d601-f103-v2 --workspace-path 'F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-agent-flow'/u);
assert.match(prompt, /hwpod-ctl spec validate --hwpod-id d601-f103-v2 --workspace-path/u);
assert.match(prompt, / spec /u);
assert.match(prompt, /standard hwpod\/hwpod-ctl commands/u);
assert.match(prompt, / hwpod\/hwpod-ctl /u);
assert.match(prompt, /hwpod job status <jobId>/u);
assert.match(prompt, / shell sleep\/&&\/timeout\/watch\/head\/pipe/u);
assert.match(prompt, /separate short `hwpod job status <jobId> --hwpod-id d601-f103-v2 --workspace-path/u);
assert.match(prompt, / shell sleep&&timeoutwatchheadpipe/u);
assert.match(prompt, / `hwpod job status <jobId> --hwpod-id d601-f103-v2 --workspace-path/u);
const diff = await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "agent-diff.patch"), "utf8");
assert.match(diff, /printf\("hello"\)/u);
assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/sessions"), true);
+71 -23
View File
@@ -206,6 +206,7 @@ type ArchivedAgentTraceSnapshot = {
export async function caseCommand(context: CaseContext) {
const subcommand = context.rest[0] || "help";
if (["help", "--help", "-h"].includes(subcommand) || context.parsed.help === true) return caseHelp();
if (subcommand === "prompt") return promptCaseRun(context);
if (subcommand === "aggregate") return aggregateCaseRun(context);
if (subcommand === "prepare") return prepareCaseRun(context);
if (subcommand === "build") return buildCaseRun(context);
@@ -237,6 +238,7 @@ export function caseHelp() {
commitId: "fixed subject repo commit id; no GitHub clone/fetch fallback"
},
commands: [
`case prompt CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
`case prepare CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
`case build CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-dir DIR]`,
`case collect CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --run-dir DIR`,
@@ -248,6 +250,7 @@ export function caseHelp() {
`case run logs RUN_ID [--tail 8000]`
],
notes: [
"case prompt 是无服务预览入口,只按真实 CaseRun 组装路径渲染输入 prompt,不准备 worktree、不提交 agent、不访问硬件。",
"case build remains a compile-only HWPOD smoke stage.",
"case run prepares the subject worktree, submits a prompt to HWLAB Code Agent, records agent provenance and workspace diff, then runs compile validation.",
"case aggregate is a service-free post-processing command: it reads an existing case registry run and writes one aggregate markdown entry without pass/fail grading.",
@@ -257,6 +260,50 @@ export function caseHelp() {
});
}
export async function promptCaseRun(context: CaseContext) {
const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId");
const caseRepo = await resolveCaseRepo(context);
const loaded = await loadCaseDefinition(caseRepo, caseId);
const runId = runIdFrom(context, caseId);
const runDir = path.resolve(context.cwd, text(context.parsed.runDir) || path.join(stateRoot(context), runId));
const subject = subjectFromDefinition(loaded.definition);
const worktreePath = subjectWorktreePath(subject.repoLocalPath, runId);
const agentTask = agentTaskFromDefinition(loaded.definition, caseId);
const now = context.now();
const run: PreparedCaseRun = {
caseId,
runId,
runDir,
caseRepo,
caseDir: loaded.caseDir,
caseFile: loaded.caseFile,
sourceSpecPath: loaded.specPath,
specPath: loaded.specPath,
apiUrl: apiUrlFrom(context, loaded.definition),
compileOnly: true,
createdAt: now,
updatedAt: now,
definition: loaded.definition,
subject: { ...subject, worktreePath, workspacePath: worktreePath, setup: { previewOnly: true } },
agentTask
};
const prompt = await renderAgentTaskPrompt(run, agentTask);
return ok("case.prompt", {
caseId,
runId,
runDir,
caseRepo,
sourceSpecPath: loaded.specPath,
compileOnly: true,
serviceRuntime: false,
previewOnly: true,
promptSha256: sha256(prompt),
agentTask: agentTaskSummary(agentTask),
subject: { repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath },
prompt
});
}
export async function aggregateCaseRun(context: CaseContext) {
const caseRepo = await resolveCaseRepo(context);
const aggregate = await writeCaseAggregateFromRegistry(context, caseRepo, { sync: true });
@@ -1080,6 +1127,7 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg
const hwpodId = text(hwpodDocument.metadata.name) || text(hwpodDocument.metadata.uid) || run.caseId;
const hwpodWorkspaceArgs = `--hwpod-id ${hwpodId} --workspace-path ${promptShellArg(run.subject.worktreePath)}`;
const constraints = [
"思维过程和输出消息一律使用中文",
...agentTask.constraints,
"只能修改 isolated subject worktree,不得修改 case registry repo。",
"不得修改原 subject repo checkout;所有源码修改必须落在 subjectWorktreePath。",
@@ -1092,41 +1140,41 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg
`hwpod build/download shell sleep/&&/timeout/watch/head/pipe shell loop JSON jobId/job_id hwpod job status <jobId> ${hwpodWorkspaceArgs} `
].filter(Boolean);
return [
`# HWPOD CaseRun Code Agent Task`,
`# HWPOD CaseRun 代码代理任务`,
``,
`caseId: ${run.caseId}`,
`runId: ${run.runId}`,
`subjectRepoLocalPath: ${run.subject.repoLocalPath}`,
`subjectCommitId: ${run.subject.commitId}`,
`subjectWorktreePath: ${run.subject.worktreePath}`,
`案例ID: ${run.caseId}`,
`运行ID: ${run.runId}`,
`主体仓库本地路径: ${run.subject.repoLocalPath}`,
`主体提交ID: ${run.subject.commitId}`,
`主体隔离工作区路径: ${run.subject.worktreePath}`,
`hwpodId: ${hwpodId}`,
`hwpodWorkspaceArgs: ${hwpodWorkspaceArgs}`,
`verificationMode: compile-only build check; no download or runtime smoke unless the case explicitly asks for it`,
`验证模式: 仅执行编译构建检查;除非案例明确要求,否则不下载、不做运行态冒烟验证。`,
``,
`## Runtime Assembly`,
`AgentRun materializes HWLAB runtime assets from ResourceBundleRef kind=gitbundle: repo subpath \`tools\` is copied to workspace \`tools\`, and repo subpath \`skills\` is copied to workspace \`.agents/skills\`. CaseRun no longer sends ad hoc workspaceFiles or seed-file payloads.`,
`## 运行时装配`,
`AgentRun 通过 ResourceBundleRef kind=gitbundle 装配 HWLAB 运行时资源:仓库子路径 \`tools\` 会复制到工作区 \`tools\`,仓库子路径 \`skills\` 会复制到工作区 \`.agents/skills\`CaseRun 不再发送临时 workspaceFiles seed-file 载荷。`,
``,
`## HWPOD Runtime`,
`Use HWPOD by id through the runtime service. Do not require a runner-local \`.hwlab/hwpod-spec.yaml\`.`,
`Use these arguments on every hwpod/hwpod-ctl command: \`${hwpodWorkspaceArgs}\`.`,
`Standard smoke sequence for this task:`,
`## HWPOD 运行时`,
`通过运行时服务按 ID 使用 HWPOD,不要求 runner 本地存在 \`.hwlab/hwpod-spec.yaml\``,
`每个 hwpod/hwpod-ctl 命令都必须携带这些参数:\`${hwpodWorkspaceArgs}\``,
`本任务的标准冒烟步骤:`,
`- \`hwpod-ctl spec validate ${hwpodWorkspaceArgs}\``,
`- \`hwpod inspect ${hwpodWorkspaceArgs}\``,
`- workspace reads/searches/edits: \`hwpod workspace ... ${hwpodWorkspaceArgs}\``,
`- compile check: \`hwpod build ${hwpodWorkspaceArgs}\``,
`- 工作区读取、搜索和编辑:\`hwpod workspace ... ${hwpodWorkspaceArgs}\``,
`- 编译检查:\`hwpod build ${hwpodWorkspaceArgs}\``,
``,
`## Task`,
`## 任务`,
agentTask.prompt,
``,
`## Constraints`,
`## 约束`,
...constraints.map((item) => `- ${item}`),
``,
`## Flow`,
`- Confirm standard \`hwpod\`, \`hwpod-ctl\` and \`hwpod-compiler\` commands are available from the gitbundle tools directory.`,
`- Follow the case task using standard hwpod/hwpod-ctl commands with \`${hwpodWorkspaceArgs}\`. Run build/download/UART steps only when the case explicitly asks for them, and report returned JSON/job/artifact/serial summaries.`,
`- For hwpod build/download, keep the HWPOD command unwrapped so it can return async JSON; then poll the returned job id with separate short \`hwpod job status <jobId> ${hwpodWorkspaceArgs}\` commands a bounded number of times. Do not wrap status polling with shell sleep, &&, timeout, watch, head, pipes, or shell loops.`,
`- CaseRun will inspect git diff under subjectWorktreePath after your turn completes and may run a runner post-check compile as separate evidence.`,
`- CaseRun records trace/session/conversation, agent commandExecution, workspace diff and Keil build evidence without auto-grading them.`
`## 执行流程`,
`- 先确认标准 \`hwpod\`\`hwpod-ctl\` \`hwpod-compiler\` 命令可从 gitbundle 工具目录调用。`,
`- 使用标准 hwpod/hwpod-ctl 命令并携带 \`${hwpodWorkspaceArgs}\` 完成案例任务。只有案例明确要求时才运行 build/download/UART 步骤,并回报返回的 JSONjobartifact 或串口摘要。`,
`- hwpod build/download,保持 HWPOD 命令本身不被包装,让它返回异步 JSON;随后用独立短命令 \`hwpod job status <jobId> ${hwpodWorkspaceArgs}\` 对返回的 job id 做有限轮询。不要用 shell sleep、&&、timeoutwatchheadpipe shell loop 包裹状态轮询。`,
`- 你的回合结束后,CaseRun 会检查 subjectWorktreePath 下的 git diff,并可能把 runner 后置编译作为单独证据记录。`,
`- CaseRun 只记录 tracesessionconversationagent 命令执行、workspace diff Keil 构建证据,不做自动评分或门禁判断。`
].join("\n");
}