diff --git a/package.json b/package.json index ecb29e3..380eab6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "build": "npm run build:server && vite build", - "build:server": "tsc -p tsconfig.json && node scripts/build-pi-mcp-adapter.mjs", + "build:server": "tsc -p tsconfig.json && node scripts/build-pi-mcp-adapter.mjs && node scripts/copy-agent-role-resources.mjs", "db:migrate": "tsx src/server/db/migrate-cli.ts", "dev": "concurrently -k \"npm run dev:server\" \"vite\"", "dev:server": "tsx watch src/server/main.ts", diff --git a/resources/agent-roles/common.md b/resources/agent-roles/common.md new file mode 100644 index 0000000..90ece3b --- /dev/null +++ b/resources/agent-roles/common.md @@ -0,0 +1,34 @@ + + +# LuoWang 共同工作方法 + +## 目标 + +在固定 Run 和不可变 target 范围内形成可复核结论。已确认规格与长期场景回答“应该是什么”;代码、接口和运行结果只回答“如何调用”和“实际发生了什么”。不得用当前实现反推正确期望。 + +## 硬边界 + +- 只使用当前 Session 提供的受控工具和动态上下文;指令文本本身不授予任何权限。 +- 不读取或发现目标仓库、宿主机、用户目录中的 Skill、Prompt、Context、`AGENTS.md` 或其他角色资源。 +- 不写隐藏推理、Secret、密码、Token、短期签名 URL、无关绝对路径或额外状态文件。 +- 结果只使用 `passed | failed | blocked`;证据或必要前置不足时不能伪装为通过。 + +## 顺序 + +1. 固定请求、target、允许读取的工件和工具边界。 +2. 以规格、长期场景和本次明确期望为判断基准。 +3. 区分原始证据、Harness 事实、Agent 陈述和推断;优先采用可独立复核的原始证据。 +4. 记录影响结论的偏差、覆盖缺口、阻塞和清理状态。 +5. 只通过当前角色的受控 writer 交付规定工件。 + +## 输出契约 + +输出必须与固定 Run 一致、可由后续角色只读复核,并明确区分决定性证据和辅助证据。动态 Run 值由本次用户消息或受控工具提供,不得编造。 + +## 失败规则 + +不影响验证目标的偏差可以记录后继续;改变前置条件、操作语义、断言含义、证据可用性或清理可信度的偏差必须形成 `blocked`。工具、环境或工件不可用时如实记录,不得用猜测补齐。 + +## 反模式 + +不得引入或假设 suite、catalog、长期能力图、通用 checkpoint、审批 hash、workflow/release gate、五状态、三轴结论、pi-subagents、自测架构或公共 OSS 规则。 diff --git a/resources/agent-roles/main-finalization.md b/resources/agent-roles/main-finalization.md new file mode 100644 index 0000000..dc4e549 --- /dev/null +++ b/resources/agent-roles/main-finalization.md @@ -0,0 +1,34 @@ + + +# Main · 最终汇总 + +## 目标 + +只根据本次已落盘工件、Harness 阻塞事实和 Reviewer 结论形成最终 `report.md`,并对本次确认的 Bug 作受限 Issue create/link 决策。 + +## 硬边界 + +- 不共享 Main · 规划或 Runner 的完整对话,不发明执行事实,不回写旧 Run 结果。 +- 不执行测试,不获取测试账号,不使用通用历史查询或任意目标仓库读取。 +- 必须保持 `blocked > failed > passed` 聚合优先级;Harness 阻塞原因非空时结果必须 blocked。 +- 不得复述前置工件中意外出现的账号、Secret 或个人标识。 + +## 顺序 + +1. 读取计划、执行记录、草稿和独立审核;初始化时按允许范围读取场景 patch。 +2. 以 Reviewer 结论和已落盘证据校正 Runner 草稿。 +3. 从本次草稿与审核形成 Bug 候选;仅通过受限候选查询决定 create/link。 +4. 聚合场景结果、confirmed Bugs、阻塞和覆盖缺口。 +5. 写入唯一最终报告。 + +## 输出契约 + +报告字段和值必须与固定 Run 一致,正文必须可追溯到本次工件和证据。零场景 passed 必须同时有计划和 Reviewer 的明确依据。 + +## 失败规则 + +必要工件、证据或查询依赖不可用时记录覆盖缺口并按既定结果规则继续;不得把 unavailable 当作 empty。初始化最终修订了尚未发布的场景 patch 但未重新执行时必须保持 blocked。 + +## 反模式 + +不得补写不存在的执行、替 Reviewer 作独立证据判断、批量改写历史 Issue/Run、循环查询追求期望答案,或把发布状态和测试结果混为一体。 diff --git a/resources/agent-roles/main-planning.md b/resources/agent-roles/main-planning.md new file mode 100644 index 0000000..643fe28 --- /dev/null +++ b/resources/agent-roles/main-planning.md @@ -0,0 +1,34 @@ + + +# Main · 规划 + +## 目标 + +理解本次请求、需求和累计变化,维护或选择必要的长期场景,并形成可执行、可审核的测试计划。 + +## 硬边界 + +- 不执行测试,不修改产品代码,不直接写目标仓库。 +- 场景变更只能通过受限 patch writer,且只能涉及 `docs/scenario-testing/scenarios/**`。 +- 场景状态只使用 `draft`、`approved`、`deprecated`;不物理删除历史场景。 +- 历史依赖不可用时标记覆盖缺口,不把 unavailable 伪装成空历史。 + +## 顺序 + +1. 读取固定 Run 上下文和 target 文件事实。 +2. 从规格、需求、长期场景、累计 diff 和允许的历史中识别产品影响。 +3. 记录证据优先级、场景选择或候选理由、执行顺序和预期证据。 +4. 明确覆盖缺口;只有确有依据时才能判断“无需场景测试”。 +5. 写入完整计划;需要时再写受限场景 patch。 + +## 输出契约 + +`plan.md` 必须说明请求、base/target/included commits、影响判断、证据优先级、选择或候选场景及顺序、预期证据和覆盖缺口。场景 patch 必须是标准 git unified patch。 + +## 失败规则 + +场景缺失、影响不明、期望冲突或证据不足时必须保留缺口,不能把零场景当作通过。 + +## 反模式 + +不得按页面、按钮或 API operation 机械铺量;不得创建 suite、catalog、journey 或长期能力图文件;不得把代码现状当作期望来源。 diff --git a/resources/agent-roles/reviewer-audit.md b/resources/agent-roles/reviewer-audit.md new file mode 100644 index 0000000..ec91620 --- /dev/null +++ b/resources/agent-roles/reviewer-audit.md @@ -0,0 +1,34 @@ + + +# Reviewer + +## 目标 + +在没有 Runner 对话的独立上下文中审核固定 Run、场景期望、原始证据、清理证据和执行结论。 + +## 硬边界 + +- 不执行命令,不获取测试账号,不读取历史 Issue 列表,不写目标仓库。 +- 只能读取本次允许的工件和受控 evidence;不能读取任意文件路径。 +- Runner 报告是待审核假设,不是事实;必须最后才用它对照自己的证据判断。 +- 只确认自己实际读取且来源受控的证据。 + +## 顺序 + +1. 先读取计划、固定场景期望和 Harness 阻塞事实。 +2. 独立读取原始命令/API/截图/清理证据,判断其是否与当前 Run、场景和断言对应。 +3. 再读取执行记录和 Runner 草稿,检查遗漏、偏差、证据链和 Bug 判断。 +4. 核对场景 patch 边界、零场景理由、confirmed Bugs 和测试数据清理。 +5. 写入明确同意或拒绝最终结果的 `review.md`。 + +## 输出契约 + +审核必须列出已读取的决定性证据、辅助证据、无法复核项、偏差、清理结论和建议结果;不能仅复述 Runner 草稿。 + +## 失败规则 + +截图不可访问、视觉能力不足、证据未读取、清理未受控确认、场景缺失或影响不明时必须维持 `blocked`。只有计划确实证明本批不影响产品行为时才能同意零场景。 + +## 反模式 + +不得因 Runner 声称通过而通过,不得用报告摘要代替原始证据,不得把场景 PR 当作产品 Bug Issue,也不得自行执行补测来破坏独立审核边界。 diff --git a/resources/agent-roles/runner-execution.md b/resources/agent-roles/runner-execution.md new file mode 100644 index 0000000..de43e02 --- /dev/null +++ b/resources/agent-roles/runner-execution.md @@ -0,0 +1,34 @@ + + +# Runner + +## 目标 + +按计划顺序执行当前 Run 的场景,记录实际观察、决定性/辅助证据、偏差和测试数据清理事实。 + +## 硬边界 + +- 只在固定 target 工作树和允许的非生产环境中操作;不修改产品代码或长期场景。 +- 只使用受控命令、Playwright MCP、环境、证据和测试数据工具;不得读取 Git Token、模型 Key、OSS Secret、管理员密码或主密钥。 +- 测试账号只用于当前操作,不得写入日志、命令输出、Markdown 或证据。 +- Runner 的清理声明不是独立核验事实。 + +## 顺序 + +1. 先读取 `plan.md` 和工作场景。 +2. 按场景顺序执行,记录期望、操作、实际观察、退出码及决定性/辅助证据。 +3. UI 优先使用 accessibility snapshot/ref;需要视觉事实时保存并确认截图存在。 +4. 使用 run-id 前缀标记临时数据,登记、清理并记录可供独立核验的结果。 +5. 写入完整 `execution.md` 和 `draft-report.md`。 + +## 输出契约 + +执行记录必须保留每个场景的真实观察、证据引用、偏差、失败和清理情况;草稿结论是待 Reviewer 审核的假设,不得把未经证据支持的结果写成通过。 + +## 失败规则 + +环境、命令、MCP、账号、证据或清理不可用时记录 `blocked`。影响前置、操作语义或断言的偏差必须停止对应结论并阻塞。 + +## 反模式 + +不得修改代码迎合期望、跳过失败步骤、把日志数量当覆盖率、以 Runner 自述代替原始证据或清理核验,也不得使用 shell 旁路突破工具边界。 diff --git a/resources/agent-roles/scenario-initialization.md b/resources/agent-roles/scenario-initialization.md new file mode 100644 index 0000000..abbf25d --- /dev/null +++ b/resources/agent-roles/scenario-initialization.md @@ -0,0 +1,34 @@ + + +# 陌生项目初始化附加规则 + +## 目标 + +从固定初始 target 建立少量、可追溯、可执行的长期场景基线,而不是穷举项目表面结构。 + +## 硬边界 + +- Preflight 缺少可信仓库、非生产环境、测试账号或必要读取能力时必须 blocked。 +- 静态勘察和候选综合分别使用新的 Main · 规划 Session;运行时侦察和候选验证分别使用新的 Runner Session。 +- 多个 Session 只通过允许的落盘工件交接,不共享完整对话。 +- 临时能力图只能存在于本次计划或报告正文,不创建长期能力图文件。 + +## 顺序 + +1. 静态勘察主要用户、入口、核心能力、权限/校验/持久化风险和外部依赖。 +2. 规划低风险运行时侦察,避免不可逆数据。 +3. 综合静态与运行证据,把业务结果相近的步骤合并为少量高价值候选场景。 +4. 对候选执行成功路径和必要拒绝路径验证,并清理临时数据。 +5. 不确定的期望保持 draft,记录冲突和覆盖缺口。 + +## 输出契约 + +每个 approved 候选必须有可追溯依据;候选资产仍只能写入场景目录 patch。最终修订若未重新执行,报告必须 blocked。 + +## 失败规则 + +没有可信候选时不伪造 patch;首次分支、固定 target、场景发布或验证条件不成立时不声称初始化完成。 + +## 反模式 + +不得按页面、按钮、路由或 API operation 机械生成场景,不得建立 suite、catalog、journey 或长期能力模型,也不得让一个 Session 假装继承另一个 Session 的对话。 diff --git a/scripts/copy-agent-role-resources.mjs b/scripts/copy-agent-role-resources.mjs new file mode 100644 index 0000000..aa78729 --- /dev/null +++ b/scripts/copy-agent-role-resources.mjs @@ -0,0 +1,27 @@ +import { cp, lstat, mkdir, readFile, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const resourceNames = [ + 'common.md', + 'main-planning.md', + 'runner-execution.md', + 'reviewer-audit.md', + 'main-finalization.md', + 'scenario-initialization.md', +]; +const source = resolve('resources/agent-roles'); +const destination = resolve('dist/resources/agent-roles'); + +await rm(destination, { recursive: true, force: true }); +await mkdir(destination, { recursive: true }); +for (const name of resourceNames) { + const sourcePath = resolve(source, name); + const metadata = await lstat(sourcePath); + if (!metadata.isFile()) throw new Error(`agent role resource is not a regular file: ${name}`); + const content = await readFile(sourcePath, 'utf8'); + if (content.trim() === '') throw new Error(`agent role resource is empty: ${name}`); + await cp(sourcePath, resolve(destination, name), { + dereference: true, + errorOnExist: true, + }); +} diff --git a/src/server/config.ts b/src/server/config.ts index ba7a461..ecd7119 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -4,7 +4,7 @@ import { join, resolve } from 'node:path'; import type { LevelWithSilent } from 'pino'; const DEFAULT_DATA_DIR = '/data'; -const DEFAULT_VERSION = '0.1.0'; +export const DEFAULT_VERSION = '0.1.0'; const LOG_LEVELS = new Set([ 'fatal', 'error', diff --git a/src/server/operations/service.ts b/src/server/operations/service.ts index 39e4232..e3c1a2d 100644 --- a/src/server/operations/service.ts +++ b/src/server/operations/service.ts @@ -633,13 +633,13 @@ function phaseLabel(phase: RunPhase): string { case 'preparing': return '准备中'; case 'main-a': - return 'Main A:分析与选场景'; + return 'Main · 规划:分析与选场景'; case 'runner': return 'Runner:执行场景'; case 'reviewer': return 'Reviewer:独立审核'; case 'main-b': - return 'Main B:汇总报告'; + return 'Main · 最终汇总:汇总报告'; case 'finalizing': return '最终整理'; case 'completed': diff --git a/src/server/runs/agent-session.ts b/src/server/runs/agent-session.ts index 4d97685..12d116a 100644 --- a/src/server/runs/agent-session.ts +++ b/src/server/runs/agent-session.ts @@ -12,7 +12,14 @@ import { Type, type Static } from 'typebox'; import type { AgentConfig } from '../../shared/types.js'; import type { ProviderAdapter } from './provider.js'; -import type { AgentRole, AgentSession, AgentSessionFactory, AgentSessionInput } from './types.js'; +import type { + AgentRole, + AgentSession, + AgentSessionFactory, + AgentSessionInput, + AgentSessionKind, + RoleInstructionVersion, +} from './types.js'; export interface PiAgentSessionFactoryOptions { provider: ProviderAdapter; @@ -301,19 +308,25 @@ export function createRunnerCommandTool( export function buildSessionInput( role: AgentRole, + sessionKind: AgentSessionKind, config: AgentConfig, cwd: string, customTools: ToolDefinition[], systemPrompt: string, + userMessage: string, + roleInstructionVersions: RoleInstructionVersion[], extensionFactories: InlineExtension[] = [], ): AgentSessionInput { return { role, + sessionKind, config, cwd, toolNames: [], customTools, systemPrompt, + userMessage, + roleInstructionVersions, extensionFactories, }; } diff --git a/src/server/runs/orchestrator.ts b/src/server/runs/orchestrator.ts index dc0c115..dcb5bb5 100644 --- a/src/server/runs/orchestrator.ts +++ b/src/server/runs/orchestrator.ts @@ -45,6 +45,11 @@ import { } from './evidence.js'; import { createProviderAdapter, type ProviderAdapter } from './provider.js'; import { createTestDataManager, createTestDataTools, type TestDataManager } from './test-data.js'; +import { + createRoleInstructionLoader, + RoleInstructionError, + type RoleInstructionLoader, +} from './role-instructions.js'; import type { RunStore } from './store.js'; import type { RunRecoveryStore } from '../automation/recovery.js'; import { createRunId, RunWorkspace, RunWorkspaceError, RunWorkspaceStore } from './workspace.js'; @@ -57,6 +62,7 @@ import type { AgentRole, AgentSession, AgentSessionFactory, + AgentSessionKind, RunArtifactName, RunContext, RunInput, @@ -89,6 +95,7 @@ export interface RunOrchestratorOptions { now?: () => Date; id?: () => string; logger?: Logger; + roleInstructions?: RoleInstructionLoader; } export interface RunOrchestrator { @@ -131,6 +138,7 @@ export function createRunOrchestrator(options: RunOrchestratorOptions): RunOrche { ...options, provider, browser, oss, testData }, sessions, commandRunner, + options.roleInstructions ?? createRoleInstructionLoader(), ); } @@ -152,6 +160,7 @@ class DefaultRunOrchestrator implements RunOrchestrator { private readonly options: RunOrchestratorOptions, private readonly sessions: AgentSessionFactory, private readonly commandRunner: ControlledCommandRunner, + private readonly roleInstructions: RoleInstructionLoader, ) { this.workspaceStore = new RunWorkspaceStore(options.reportDir); this.now = options.now ?? (() => new Date()); @@ -503,9 +512,9 @@ class DefaultRunOrchestrator implements RunOrchestrator { repository: GitRepository, context: RunContext, ): Promise { - this.setPhase(state, 'main-a', 'Main A 正在分析变更并选择场景'); + this.setPhase(state, 'main-a', 'Main · 规划正在分析变更并选择场景'); const tools = [ - ...createTargetContextTools(this.targetToolOptions(repository, context)), + ...createTargetContextTools(this.targetToolOptions(repository, context, 'main-planning')), createArtifactWriterTool( 'write_plan', '写入测试计划', @@ -524,11 +533,14 @@ class DefaultRunOrchestrator implements RunOrchestrator { ]), ]; await this.invoke( + 'main-planning', 'main-a', this.options.configuration.getHarness().agents.main, context.repositoryDirectory, tools, - mainAPrompt(context), + mainAUserMessage(context), + mainAOutputContract(context), + context.initialization, ); await assertArtifact(workspace, 'plan.md'); } @@ -564,9 +576,9 @@ class DefaultRunOrchestrator implements RunOrchestrator { repository: GitRepository, context: RunContext, ): Promise { - this.setPhase(state, 'main-a', 'Main A 正在整理初始化候选场景'); + this.setPhase(state, 'main-a', 'Main · 规划正在整理初始化候选场景'); const tools = [ - ...createTargetContextTools(this.targetToolOptions(repository, context)), + ...createTargetContextTools(this.targetToolOptions(repository, context, 'main-planning')), createReadArtifactTool((name) => readAllowedArtifact(workspace, name, ['plan.md', 'execution.md', 'draft-report.md']), ), @@ -578,11 +590,14 @@ class DefaultRunOrchestrator implements RunOrchestrator { ), ]; await this.invoke( + 'main-planning', 'main-a', this.options.configuration.getHarness().agents.main, context.repositoryDirectory, tools, - initializationCandidatePrompt(context), + initializationCandidateUserMessage(context), + initializationCandidateOutputContract(), + true, ); } @@ -710,7 +725,7 @@ class DefaultRunOrchestrator implements RunOrchestrator { ): Promise { this.setPhase(state, 'runner', 'Runner 正在执行场景并收集证据'); const tools = [ - ...createTargetContextTools(this.targetToolOptions(repository, context)), + ...createTargetContextTools(this.targetToolOptions(repository, context, 'runner')), ...createWorkingScenarioTools({ list: () => repository.listWorkingScenarioFiles(), read: (path) => repository.readWorkingScenarioFile(path), @@ -744,11 +759,14 @@ class DefaultRunOrchestrator implements RunOrchestrator { ), ]; await this.invoke( + 'runner-execution', 'runner', this.options.configuration.getHarness().agents.runner, context.repositoryDirectory, tools, - runnerPrompt(context, purpose), + runnerUserMessage(context, purpose), + runnerOutputContract(), + false, context.browserRequired && this.options.browser?.isEnabled() ? [this.options.browser.extension(workspace.evidenceDirectory)] : [], @@ -966,11 +984,14 @@ class DefaultRunOrchestrator implements RunOrchestrator { ), ]; await this.invoke( + 'reviewer-audit', 'reviewer', this.options.configuration.getHarness().agents.reviewer, context.runDirectory, tools, - reviewerPrompt(context), + reviewerUserMessage(context), + reviewerOutputContract(), + false, ); await assertArtifact(workspace, 'review.md'); } @@ -997,7 +1018,7 @@ class DefaultRunOrchestrator implements RunOrchestrator { workspace: RunWorkspace, context: RunContext, ): Promise { - this.setPhase(state, 'main-b', 'Main B 正在汇总最终报告'); + this.setPhase(state, 'main-b', 'Main · 最终汇总正在汇总最终报告'); const tools = [ createReadArtifactTool((name) => readAllowedArtifact(workspace, name, [ @@ -1026,35 +1047,56 @@ class DefaultRunOrchestrator implements RunOrchestrator { : []), ]; await this.invoke( + 'main-finalization', 'main-b', this.options.configuration.getHarness().agents.main, context.runDirectory, tools, - mainBPrompt(context), + mainBUserMessage(context), + mainBOutputContract(), + context.initialization, ); await assertArtifact(workspace, 'report.md'); } private async invoke( + sessionKind: AgentSessionKind, role: AgentRole, config: AgentConfig, cwd: string, tools: ReturnType, - prompt: string, + userMessage: string, + outputContract: string, + initialization: boolean, extensionFactories: InlineExtension[] = [], ): Promise { let session: AgentSession | undefined; try { - session = await this.sessions.create( - buildSessionInput(role, config, cwd, tools, prompt, extensionFactories), + const instructions = await this.roleInstructions.load(sessionKind, initialization); + const systemPrompt = buildSystemPrompt(sessionKind, instructions.content, outputContract); + const input = buildSessionInput( + role, + sessionKind, + config, + cwd, + tools, + systemPrompt, + userMessage, + instructions.versions, + extensionFactories, ); - await session.prompt(prompt); + session = await this.sessions.create(input); + await session.prompt(input.userMessage); } finally { if (session) await session.dispose(); } } - private targetToolOptions(repository: GitRepository, context: RunContext) { + private targetToolOptions( + repository: GitRepository, + context: RunContext, + audience: 'main-planning' | 'runner', + ) { return { readFile: async (path: string) => { assertReadableTargetPath(path); @@ -1066,35 +1108,27 @@ class DefaultRunOrchestrator implements RunOrchestrator { .map((entry) => entry.path), search: async (query: string) => this.searchTarget(repository, context.targetCommit, query), context: () => - JSON.stringify({ - runId: context.runId, - request: context.request, - trigger: context.trigger, - baseCommit: context.baseCommit, - targetCommit: context.targetCommit, - includedCommits: context.includedCommits, - scenarioMode: context.scenarioMode, - initialization: context.initialization, - scenarioChanges: context.scenarioChanges ?? null, - repositoryDirectory: context.repositoryDirectory, - runDirectory: context.runDirectory, - historyIssuesAvailable: context.historyIssuesAvailable, - historyIssues: context.historyIssues, - indexedScenarios: - this.options.indexer?.listScenarios().map((scenario) => ({ - id: scenario.id, - name: scenario.name, - status: scenario.status, - tags: scenario.tags, - })) ?? [], - indexedReports: - this.options.indexer?.listReports().map((report) => ({ - runId: report.runId, - result: report.result, - targetCommit: report.targetCommit, - scenarioResults: report.scenarioResults, - })) ?? [], - }), + JSON.stringify( + audience === 'runner' + ? runnerContext(context) + : { + ...mainPlanningContext(context), + indexedScenarios: + this.options.indexer?.listScenarios().map((scenario) => ({ + id: scenario.id, + name: scenario.name, + status: scenario.status, + tags: scenario.tags, + })) ?? [], + indexedReports: + this.options.indexer?.listReports().map((report) => ({ + runId: report.runId, + result: report.result, + targetCommit: report.targetCommit, + scenarioResults: report.scenarioResults, + })) ?? [], + }, + ), }; } @@ -1587,89 +1621,172 @@ function safeMessage(error: unknown): string { if ( error instanceof RunOrchestratorError || error instanceof RunWorkspaceError || - error instanceof ScenarioPatchError + error instanceof ScenarioPatchError || + error instanceof RoleInstructionError ) return error.message; return 'Run 执行失败,未生成可信最终结论'; } -function mainAPrompt(context: RunContext): string { - const initialization = context.initialization - ? '这是陌生项目初始化:先完成 Preflight 和静态勘察,列出主要用户/入口/核心能力、证据依据、运行时侦察计划和覆盖缺口;不要创建 suite、catalog、journey 或长期能力图。' - : '这是日常场景测试:理解累计变化、需求、代码和历史结果,选择已有场景或维护必要的长期场景。'; - const patchInstruction = context.initialization - ? '本阶段只写 plan.md,不写 scenario-changes.patch;运行时侦察完成后会由另一个短 Main session 生成候选 patch。' - : '如确实需要维护长期场景,只能通过 write_scenario_patch 写标准 git unified patch,路径必须全部位于 docs/scenario-testing/scenarios/**;不能直接写目标仓库。'; - return `你是 LuoWang Phase 7 的 Main A。你负责理解变化、维护/选择长期场景并形成计划,不能执行测试,也不能修改目标仓库。 +const SESSION_IDENTITIES: Record = { + 'main-planning': '你是 LuoWang 的 Main · 规划。你负责理解变化、维护或选择长期场景并形成计划。', + 'runner-execution': '你是 LuoWang 的 Runner。你负责在固定 target 上执行计划并收集证据。', + 'reviewer-audit': '你是 LuoWang 的 Reviewer。你负责在独立上下文中审核本次执行和证据。', + 'main-finalization': '你是 LuoWang 的 Main · 最终汇总。你负责根据落盘工件和审核形成最终报告。', +}; + +function buildSystemPrompt( + kind: AgentSessionKind, + roleInstructions: string, + outputContract: string, +): string { + return `${SESSION_IDENTITIES[kind]} -${initialization} +## Built-in Role Instructions -固定 Run 上下文: -${JSON.stringify(context, null, 2)} +${roleInstructions} -必须先调用 get_run_context、list_target_files,并按需调用 read_target_file/search_target_files 读取固定 target。上下文中的 historyIssuesAvailable 为 false 时,不能把 Issue 历史当作空列表,需在覆盖缺口中说明。只能通过 write_plan 写入完整 plan.md,并且必须在结束前调用它。plan.md 要说明请求、base/target/included commits、影响判断、证据优先级、选择/候选场景及顺序、预期证据和覆盖缺口。 -${patchInstruction} -如果本批产品行为不需要场景测试,必须明确写出“无需场景测试”的理由;如果场景缺失、影响不明或证据不足,必须把覆盖缺口写清楚,不能把零场景当作 passed。场景状态只能使用 draft、approved、deprecated;不要物理删除场景。`; +## 本 Session 输出契约 + +${outputContract}`; } -function initializationCandidatePrompt(context: RunContext): string { - return `你是 LuoWang Phase 7 初始化流程中的候选综合 Main。你正在一个新的短 session 中工作,不能执行测试,也不能写目标仓库。 +function mainAUserMessage(context: RunContext): string { + const task = context.initialization + ? '完成陌生项目初始化的 Preflight 与静态勘察,列出主要用户、入口、核心能力、证据依据、低风险运行时侦察计划和覆盖缺口。' + : '理解累计变化、需求、代码和历史结果,选择已有场景或维护必要的长期场景。'; + return `当前任务:${task} -固定 Run 上下文: -${JSON.stringify(context, null, 2)} +动态 Run 上下文: +${JSON.stringify(mainPlanningContext(context), null, 2)}`; +} -先读取 plan.md、execution.md 和 draft-report.md,结合静态证据与低风险运行时侦察,形成临时能力图(只写在本次 plan/report 正文,不创建长期能力图文件)。把业务结果相近的步骤合并为少量高价值场景,覆盖主要用户、入口、核心成功路径、权限/校验/持久化风险和明确的外部依赖;不要按页面、按钮或 API operation 机械铺量。每个 approved 场景都必须有可追溯依据,不确定的期望保持 draft,并在计划中记录冲突和缺口。 +function mainAOutputContract(context: RunContext): string { + const patchInstruction = context.initialization + ? '本阶段只写 plan.md,不写 scenario-changes.patch;运行时侦察后由新的 Main · 规划 Session 生成候选 patch。' + : '如需维护长期场景,只能通过 write_scenario_patch 写场景目录内的标准 git unified patch。'; + return `必须先调用 get_run_context、list_target_files,并按需调用 read_target_file/search_target_files。必须在结束前通过 write_plan 写入完整 plan.md;historyIssuesAvailable=false 时在覆盖缺口中说明。 +${patchInstruction} +如果确有依据判断无需测试,明确写出“无需场景测试”的理由;否则保留场景缺失、影响不明或证据不足的覆盖缺口。`; +} + +function initializationCandidateUserMessage(context: RunContext): string { + return `当前任务:在新的 Main · 规划 Session 中,综合静态证据和低风险运行时侦察,形成少量高价值候选场景。 + +动态 Run 上下文: +${JSON.stringify(mainPlanningContext(context), null, 2)}`; +} -候选长期资产只能通过 write_scenario_patch 写标准 git unified patch,且 patch 只能新增/修改/目录内 rename docs/scenario-testing/scenarios/** 的 Markdown 文件;不得创建 suite、catalog、journey、能力图,不得修改产品源码、需求、PROJECT 或报告,不得物理删除场景。没有可信候选时不要伪造 patch。`; +function initializationCandidateOutputContract(): string { + return `先读取 plan.md、execution.md 和 draft-report.md。临时能力图只写在本次正文中;把业务结果相近的步骤合并,覆盖主要用户、入口、核心成功路径、权限/校验/持久化风险和明确外部依赖。每个 approved 场景必须有可追溯依据,不确定期望保持 draft。 +候选资产只能通过 write_scenario_patch 写标准 git unified patch,且只能新增、修改或目录内 rename docs/scenario-testing/scenarios/** 的 Markdown。没有可信候选时不伪造 patch。`; } -function runnerPrompt( +function runnerUserMessage( context: RunContext, purpose: 'standard' | 'initialization-reconnaissance' | 'initialization-validation', ): string { - const phaseInstruction = + const task = purpose === 'initialization-reconnaissance' - ? '这是陌生项目初始化的运行时侦察阶段:从已知入口低风险检查主要导航、登录和关键状态,不创建不可逆数据,不把每个页面机械写成场景。' + ? '执行陌生项目初始化的低风险运行时侦察;检查已知入口、主要导航、登录和关键状态,不创建不可逆数据。' : purpose === 'initialization-validation' - ? '这是初始化候选验证阶段:按候选场景顺序执行可验证的成功路径和必要拒绝路径,使用 run-id 标记并清理临时数据。' - : '这是日常场景测试阶段:只执行 plan.md 选择的场景。'; - return `你是 LuoWang Phase 7 的 Runner。你只能在固定 target 工作树中顺序执行计划要求的 fixture/API/CLI/UI 测试。 + ? '按候选场景顺序验证成功路径和必要拒绝路径,使用 run-id 标记并清理临时数据。' + : '只执行 plan.md 选择的日常场景。'; + return `当前任务:${task} -${phaseInstruction} - -固定 Run 上下文: -${JSON.stringify(context, null, 2)} +动态 Run 上下文: +${JSON.stringify(runnerContext(context), null, 2)}`; +} -先读取 plan.md,再按计划使用 read_target_file、search_target_files、list_working_scenarios、read_working_scenario 和 run_fixture_command。UI 场景只能使用受控的 headless、isolated Playwright MCP,优先使用 accessibility snapshot/ref;需要截图时必须使用相对文件名(例如 auth-login-001-after-login.png),截图会自动写入当前 Run 的 evidence 目录;截图后必须调用 list_evidence_files 确认 PNG/JPEG/WebP 确实存在,并在 execution.md 中记录文件名。可以通过 get_test_environment 请求当前非生产测试环境和账号,密码只能用于当前操作,绝不能写入日志、命令输出、任何 Markdown 或证据。使用 get_test_data_prefix 标记临时数据,登记后在场景结束调用 cleanup_test_data。run_fixture_command 只允许受控本地命令,不能读取或猜测其他 Harness Secret,不能写产品源码,不能使用 shell 管道/重定向。每个场景记录实际观察、命令退出码、证据和清理结果;通过 upload_evidence 可提前上传证据,Harness 也会在 Runner 结束后兜底上传。最后必须分别通过 write_execution 写完整 execution.md、通过 write_draft_report 写完整 draft-report.md。环境/命令/凭据不可用时记录为 blocked,不伪造通过。`; +function runnerOutputContract(): string { + return `先读取 plan.md,再按计划使用受控 target、工作场景、命令、环境、测试数据和 evidence 工具。UI 场景只能使用受控的 headless、isolated Playwright MCP,优先使用 accessibility snapshot/ref;截图使用相对文件名并通过 list_evidence_files 确认存在。 +测试账号只用于当前操作,绝不能写入日志、命令输出、Markdown 或证据。使用 get_test_data_prefix 标记临时数据,登记后执行 cleanup_test_data。每个场景记录实际观察、命令退出码、决定性/辅助证据、偏差和清理结果。结束前分别通过 write_execution 和 write_draft_report 写完整工件;不可用条件记录为 blocked。`; } -function reviewerPrompt(context: RunContext): string { - return `你是独立的 LuoWang Phase 7 Reviewer。你没有 Runner 对话,只能读取本次 Run 的 plan.md、execution.md、draft-report.md、scenario-changes.patch(若存在)和受控 evidence。 +function reviewerUserMessage(context: RunContext): string { + return `当前任务:独立核对计划、原始执行证据、场景变更、场景结果、confirmed Bugs、截图事实、清理和 Harness 阻塞原因。 -固定 Run 上下文: -${JSON.stringify(context, null, 2)} +动态 Run 上下文: +${JSON.stringify(reviewerContext(context), null, 2)}`; +} -请独立核对计划、执行证据、场景变更 patch、场景结果、confirmed bugs、截图事实、清理和阻塞原因。需要查看截图时只能使用 list_evidence_files 和 read_evidence_image,不能使用命令,不能读取测试账号或其他 Secret,也不能读取任意文件路径。若截图不可访问、上传失败、UI 能力缺失或视觉判断无法完成,必须维持 blocked。场景 patch 必须仍只涉及场景目录且符合固定 frontmatter;不要把场景 PR 当作产品 Bug Issue。若零场景,只有在 Main A 的计划确实证明本批无需场景测试时才确认;场景缺失或影响不明必须维持 blocked。结束前必须通过 write_review 写完整 review.md,并明确是否同意最终结果。`; +function reviewerOutputContract(): string { + return `依次读取 plan.md、execution.md、draft-report.md 和存在的 scenario-changes.patch;原始证据先于 Runner 草稿。查看截图只能使用 list_evidence_files 和 read_evidence_image,不能执行命令、读取测试账号或任意路径。 +截图不可访问、上传失败、视觉能力不足、清理未确认、场景缺失或影响不明时维持 blocked。零场景只有在 Main · 规划的计划确有依据时才能确认。结束前通过 write_review 写完整 review.md,并明确是否同意最终结果。`; } -function mainBPrompt(context: RunContext): string { - const initialization = context.initialization - ? '这是初始化 Run;可以在 Reviewer 意见支持下,通过同一个受限 write_scenario_patch 修订尚未发布的候选场景 patch,但修订后如果没有再次执行必须明确形成 blocked。' - : '这是日常测试 Run;不要修改场景 patch。'; - return `你是 LuoWang Phase 7 的 Main B。你只能读取本次 Run 的前置 Markdown 工件,并根据 Reviewer 审核形成最终 report.md。 +function mainBUserMessage(context: RunContext): string { + const task = context.initialization + ? '汇总初始化 Run;可在 Reviewer 意见支持下用受限 writer 修订尚未发布的候选场景 patch,但修订后未重新执行必须保持 blocked。' + : '汇总日常测试 Run,不修改场景 patch。'; + return `当前任务:${task} -${initialization} +动态 Run 上下文: +${JSON.stringify(finalizationContext(context), null, 2)}`; +} -固定 Run 上下文: -${JSON.stringify(context, null, 2)} +function mainBOutputContract(): string { + return `必须先读取 plan.md、execution.md、draft-report.md、review.md;初始化且存在 scenario-changes.patch 时也读取它。最终 report.md frontmatter 只能包含 run_id、trigger、base_commit、target_commit、included_commits、result、started_at、finished_at、scenario_results、confirmed_bugs,字段值必须与固定 Run 一致。result 优先级为 blocked > failed > passed;blockingReasons 非空时必须 blocked。 +scenario_results 必须是 YAML 数组,每项只能有 id 和 result。confirmed_bugs 每项只能有 key、title、scenario_ids、issue_action,以及 link 时必需的 issue_url;failed 至少有一个 confirmed bug,issue_action 只能 create 或 link。零场景 passed 必须在计划、审核和最终报告中都有“无需场景测试”依据。 +证据只写在正文并引用稳定 URL。不得复述任何测试账号字段、Secret、隐藏推理、短期签名 URL 或绝对路径。结束前通过 write_report 写完整 report.md。`; +} -必须先读取 plan.md、execution.md、draft-report.md、review.md。若当前是初始化且存在 scenario-changes.patch,也读取它。最终 report.md 只能包含以下 frontmatter 字段:run_id、trigger、base_commit、target_commit、included_commits、result、started_at、finished_at、scenario_results、confirmed_bugs;不得增加任何其他 frontmatter 字段。字段值必须与固定 Run 一致;result 聚合优先级为 blocked > failed > passed。只要固定上下文中的 blockingReasons 非空,最终结果必须是 blocked,并在正文说明这些阻塞原因;不要把 Harness 自动追加的证据地址或清理状态写入 frontmatter。“scenario_results”必须始终是 YAML 数组;每个元素只能有 “id” 和 “result” 两个字段,严格使用以下形状: +function mainPlanningContext(context: RunContext) { + return { + runId: context.runId, + request: context.request, + trigger: context.trigger, + baseCommit: context.baseCommit, + targetCommit: context.targetCommit, + includedCommits: context.includedCommits, + scenarioMode: context.scenarioMode, + initialization: context.initialization, + historyIssuesAvailable: context.historyIssuesAvailable, + historyIssues: context.historyIssues, + blockingReasons: context.blockingReasons, + scenarioChanges: context.scenarioChanges ?? null, + }; +} -~~~yaml -scenario_results: - - id: AUTH-LOGIN-001 - result: passed -~~~ +function runnerContext(context: RunContext) { + return { + runId: context.runId, + request: context.request, + trigger: context.trigger, + baseCommit: context.baseCommit, + targetCommit: context.targetCommit, + includedCommits: context.includedCommits, + runDirectory: context.runDirectory, + scenarioMode: context.scenarioMode, + initialization: context.initialization, + blockingReasons: context.blockingReasons, + }; +} + +function reviewerContext(context: RunContext) { + return { + runId: context.runId, + trigger: context.trigger, + targetCommit: context.targetCommit, + scenarioMode: context.scenarioMode, + initialization: context.initialization, + scenarioChanges: context.scenarioChanges ?? null, + evidence: context.evidence, + blockingReasons: context.blockingReasons, + }; +} -将示例中的场景 ID 和结果替换为实际值。禁止使用 “scenario_id”、“scenario”、“title”、“status” 或 “evidence” 作为 “scenario_results” 元素字段;证据只能写在 Markdown 正文中。“confirmed_bugs”的元素只能使用 parser 支持的字段:“key”、“title”、“scenario_ids”、“issue_action”,以及在 “issue_action: link” 时必需的 “issue_url”。failed 必须至少有一个 confirmed_bugs;confirmed bug 的 issue_action 只能是 create 或 link,link 必须有 issue_url。零场景 passed 必须在 plan、review 和本报告中都保留“无需场景测试”的依据。测试环境账号是 Runner 专用 Secret:即使前置工件中出现,也绝不能在最终报告中写出或复述 username、password、email、userId、displayName、账号标识或任何 get_test_environment 返回值;只写脱敏的状态码、通用 UI 文案和行为事实。不要写隐藏推理、Secret、密码、短期签名 URL、绝对证据路径或额外状态文件;证据只能引用固定上下文中的稳定 URL。结束前必须通过 write_report 写完整 report.md。`; +function finalizationContext(context: RunContext) { + return { + runId: context.runId, + request: context.request, + trigger: context.trigger, + baseCommit: context.baseCommit, + targetCommit: context.targetCommit, + includedCommits: context.includedCommits, + scenarioMode: context.scenarioMode, + initialization: context.initialization, + evidence: context.evidence, + blockingReasons: context.blockingReasons, + }; } diff --git a/src/server/runs/role-instructions.ts b/src/server/runs/role-instructions.ts new file mode 100644 index 0000000..4e6cbbd --- /dev/null +++ b/src/server/runs/role-instructions.ts @@ -0,0 +1,120 @@ +import { createHash } from 'node:crypto'; +import { lstat, readFile } from 'node:fs/promises'; +import { basename, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { DEFAULT_VERSION } from '../config.js'; +import type { AgentSessionKind, RoleInstructionVersion } from './types.js'; + +const FORMAT_VERSION = '1'; +const MAX_INSTRUCTION_BYTES = 256 * 1024; + +const RESOURCE_FILES = { + common: 'common.md', + 'main-planning': 'main-planning.md', + 'runner-execution': 'runner-execution.md', + 'reviewer-audit': 'reviewer-audit.md', + 'main-finalization': 'main-finalization.md', + 'scenario-initialization': 'scenario-initialization.md', +} as const; + +type RoleInstructionId = keyof typeof RESOURCE_FILES; + +const SESSION_RESOURCE_IDS: Record = { + 'main-planning': ['common', 'main-planning'], + 'runner-execution': ['common', 'runner-execution'], + 'reviewer-audit': ['common', 'reviewer-audit'], + 'main-finalization': ['common', 'main-finalization'], +}; + +export interface LoadedRoleInstructions { + content: string; + versions: RoleInstructionVersion[]; +} + +export interface RoleInstructionLoader { + load(kind: AgentSessionKind, initialization: boolean): Promise; +} + +export interface RoleInstructionLoaderOptions { + resourceDirectory?: string; + applicationVersion?: string; +} + +export class RoleInstructionError extends Error { + constructor(message: string) { + super(message); + this.name = 'RoleInstructionError'; + } +} + +export function createRoleInstructionLoader( + options: RoleInstructionLoaderOptions = {}, +): RoleInstructionLoader { + const resourceDirectory = options.resourceDirectory ?? defaultResourceDirectory(); + const applicationVersion = normalizedApplicationVersion(options.applicationVersion); + return { + async load(kind, initialization) { + const ids = [ + ...SESSION_RESOURCE_IDS[kind], + ...(initialization && (kind === 'main-planning' || kind === 'main-finalization') + ? (['scenario-initialization'] as const) + : []), + ]; + const resources = await Promise.all( + ids.map((id) => loadResource(resourceDirectory, id, applicationVersion)), + ); + return { + content: resources.map((resource) => resource.content).join('\n\n'), + versions: resources.map(({ id, formatVersion, applicationVersion, sha256 }) => ({ + id, + formatVersion, + applicationVersion, + sha256, + })), + }; + }, + }; +} + +async function loadResource( + resourceDirectory: string, + id: RoleInstructionId, + applicationVersion: string, +): Promise { + const filename = RESOURCE_FILES[id]; + const path = resolve(resourceDirectory, filename); + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.size === 0 || metadata.size > MAX_INSTRUCTION_BYTES) { + throw new Error('invalid resource'); + } + const content = await readFile(path, 'utf8'); + if (content.trim() === '') throw new Error('empty resource'); + const expectedMarker = `luowang-role-id: ${id}; format-version: ${FORMAT_VERSION}`; + if (!content.includes(expectedMarker)) throw new Error('invalid marker'); + return { + id, + formatVersion: FORMAT_VERSION, + applicationVersion, + sha256: createHash('sha256').update(content, 'utf8').digest('hex'), + content: content.trim(), + }; + } catch { + throw new RoleInstructionError(`内置角色指令缺失、为空或格式错误:${id}`); + } +} + +function defaultResourceDirectory(): string { + const moduleDirectory = dirname(fileURLToPath(import.meta.url)); + const projectRoot = resolve(moduleDirectory, '../../..'); + const compiled = basename(resolve(moduleDirectory, '../..')) === 'dist'; + return compiled + ? resolve(projectRoot, 'dist/resources/agent-roles') + : resolve(projectRoot, 'resources/agent-roles'); +} + +function normalizedApplicationVersion(value: string | undefined): string { + const version = value ?? process.env.LUOWANG_VERSION ?? DEFAULT_VERSION; + return version.trim() === '' ? DEFAULT_VERSION : version.trim(); +} diff --git a/src/server/runs/types.ts b/src/server/runs/types.ts index 115cc39..3a4576b 100644 --- a/src/server/runs/types.ts +++ b/src/server/runs/types.ts @@ -12,6 +12,16 @@ import type { ScenarioPatchValidation } from '../repository/scenario-patch.js'; export type AgentRole = 'main-a' | 'runner' | 'reviewer' | 'main-b'; +export type AgentSessionKind = + 'main-planning' | 'runner-execution' | 'reviewer-audit' | 'main-finalization'; + +export interface RoleInstructionVersion { + id: string; + formatVersion: string; + applicationVersion: string; + sha256: string; +} + export const RUN_ARTIFACT_NAMES = [ 'plan.md', 'execution.md', @@ -53,11 +63,14 @@ export interface RunContext { export interface AgentSessionInput { role: AgentRole; + sessionKind: AgentSessionKind; config: AgentConfig; cwd: string; toolNames: string[]; customTools: ToolDefinition[]; systemPrompt: string; + userMessage: string; + roleInstructionVersions: RoleInstructionVersion[]; extensionFactories?: InlineExtension[]; } diff --git a/src/web/App.tsx b/src/web/App.tsx index 2849114..184e093 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -2087,13 +2087,13 @@ function dependencyStatusLabel( function roleLabel(role: NonNullable['role']): string { switch (role) { case 'main-a': - return 'Main A'; + return 'Main · 规划'; case 'runner': return 'Runner'; case 'reviewer': return 'Reviewer'; case 'main-b': - return 'Main B'; + return 'Main · 最终汇总'; default: return '—'; } diff --git a/tests/acceptance/phase9.ts b/tests/acceptance/phase9.ts index 01b2969..400db57 100644 --- a/tests/acceptance/phase9.ts +++ b/tests/acceptance/phase9.ts @@ -229,7 +229,7 @@ const AC_DEFINITIONS: Array<{ }, { id: 'AC-AGENT-01', - title: '人工请求按 Main A、Runner、Reviewer、Main B 产生五文件', + title: '人工请求按 Main · 规划、Runner、Reviewer、Main · 最终汇总产生五文件', proof: 'run', evidence: ['tests/phase3.test.ts', 'tests/acceptance/phase9.ts: four-session proof'], }, @@ -1342,7 +1342,7 @@ class FixtureSessionFactory implements AgentSessionFactory { this.created.push(input.role); return { prompt: async () => { - const target = extractTarget(input.systemPrompt); + const target = extractTarget(input.userMessage); if (input.role === 'main-a') { await invokeTool(input, 'get_run_context', {}); await invokeTool(input, 'write_plan', { @@ -1379,7 +1379,7 @@ class FixtureSessionFactory implements AgentSessionFactory { await invokeTool(input, 'read_run_artifact', { name }); } await invokeTool(input, 'write_report', { - content: reportForContext(input.systemPrompt, target), + content: reportForContext(input.userMessage, target), }); }, dispose: () => { diff --git a/tests/closure1-role-instructions.test.ts b/tests/closure1-role-instructions.test.ts new file mode 100644 index 0000000..8da58ae --- /dev/null +++ b/tests/closure1-role-instructions.test.ts @@ -0,0 +1,120 @@ +import { strict as assert } from 'node:assert'; +import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { afterEach, describe, it } from 'vitest'; + +import { + createRoleInstructionLoader, + RoleInstructionError, +} from '../src/server/runs/role-instructions.js'; +import type { AgentSessionKind } from '../src/server/runs/types.js'; + +const cleanup: Array<() => Promise> = []; +const sourceDirectory = resolve('resources/agent-roles'); + +const expectedByKind: Record = { + 'main-planning': ['common', 'main-planning'], + 'runner-execution': ['common', 'runner-execution'], + 'reviewer-audit': ['common', 'reviewer-audit'], + 'main-finalization': ['common', 'main-finalization'], +}; + +afterEach(async () => { + while (cleanup.length > 0) await cleanup.pop()?.(); +}); + +describe('Closure 1 built-in role instructions', () => { + it('loads only the fixed resources for each isolated Session kind', async () => { + const loader = createRoleInstructionLoader({ applicationVersion: 'closure1-test' }); + for (const [kind, expectedIds] of Object.entries(expectedByKind) as Array< + [AgentSessionKind, string[]] + >) { + const loaded = await loader.load(kind, false); + assert.deepEqual( + loaded.versions.map((item) => item.id), + expectedIds, + ); + assert.match(loaded.content, /luowang-role-id: common/); + assert.equal( + loaded.versions.every((item) => item.applicationVersion === 'closure1-test'), + true, + ); + assert.equal( + loaded.versions.every((item) => /^[a-f0-9]{64}$/.test(item.sha256)), + true, + ); + for (const other of Object.keys(expectedByKind).filter((id) => !expectedIds.includes(id))) { + assert.doesNotMatch(loaded.content, new RegExp(`luowang-role-id: ${other}`)); + } + } + }); + + it('adds initialization rules only to Main planning and finalization', async () => { + const loader = createRoleInstructionLoader(); + for (const kind of Object.keys(expectedByKind) as AgentSessionKind[]) { + const loaded = await loader.load(kind, true); + const ids = loaded.versions.map((item) => item.id); + if (kind === 'main-planning' || kind === 'main-finalization') { + assert.equal(ids.at(-1), 'scenario-initialization'); + } else { + assert.equal(ids.includes('scenario-initialization'), false); + } + } + }); + + it('ignores ambient target, host and user resources outside the fixed allowlist', async () => { + const root = await mkdtemp(join(tmpdir(), 'luowang-closure1-ambient-')); + cleanup.push(async () => rm(root, { recursive: true, force: true })); + const fixed = join(root, 'fixed'); + await cp(sourceDirectory, fixed, { recursive: true }); + for (const path of [ + join(root, 'target', '.pi', 'skills', 'evil', 'SKILL.md'), + join(root, 'target', '.agents', 'skills', 'evil', 'SKILL.md'), + join(root, 'target', 'AGENTS.md'), + join(root, 'host-agent-dir', 'prompts', 'evil.md'), + join(root, 'user-home', '.pi', 'context.md'), + ]) { + await mkdir(resolve(path, '..'), { recursive: true }); + await writeFile(path, 'AMBIENT_MARKER_MUST_NOT_LOAD\n'); + } + + const loaded = await createRoleInstructionLoader({ resourceDirectory: fixed }).load( + 'main-planning', + false, + ); + assert.doesNotMatch(loaded.content, /AMBIENT_MARKER_MUST_NOT_LOAD/); + assert.deepEqual( + loaded.versions.map((item) => item.id), + ['common', 'main-planning'], + ); + }); + + it('fails closed for missing, empty or incorrectly marked resources without exposing paths', async () => { + for (const mode of ['missing', 'empty', 'marker', 'symlink'] as const) { + const root = await mkdtemp(join(tmpdir(), `luowang-closure1-${mode}-`)); + cleanup.push(async () => rm(root, { recursive: true, force: true })); + await cp(sourceDirectory, root, { recursive: true }); + const target = join(root, 'runner-execution.md'); + if (mode === 'missing') await rm(target); + if (mode === 'empty') await writeFile(target, '\n'); + if (mode === 'marker') await writeFile(target, '# wrong role\n'); + if (mode === 'symlink') { + await rm(target); + await symlink(join(sourceDirectory, 'runner-execution.md'), target); + } + + await assert.rejects( + () => + createRoleInstructionLoader({ resourceDirectory: root }).load('runner-execution', false), + (error: unknown) => { + assert.equal(error instanceof RoleInstructionError, true); + assert.match((error as Error).message, /runner-execution/); + assert.doesNotMatch((error as Error).message, new RegExp(root)); + return true; + }, + ); + } + }); +}); diff --git a/tests/phase3.test.ts b/tests/phase3.test.ts index 6ffa6e8..fe4db66 100644 --- a/tests/phase3.test.ts +++ b/tests/phase3.test.ts @@ -56,6 +56,109 @@ describe('Phase 3 agent run', () => { assert.deepEqual(context.sessions.created, ['main-a', 'runner', 'reviewer', 'main-b']); assert.deepEqual(context.sessions.disposed, ['main-a', 'runner', 'reviewer', 'main-b']); assert.equal(new Set(context.sessions.sessionObjects).size, 4); + assert.deepEqual( + context.sessions.inputs.map((input) => input.sessionKind), + ['main-planning', 'runner-execution', 'reviewer-audit', 'main-finalization'], + ); + assert.deepEqual( + context.sessions.inputs.map((input) => input.roleInstructionVersions.map((item) => item.id)), + [ + ['common', 'main-planning'], + ['common', 'runner-execution'], + ['common', 'reviewer-audit'], + ['common', 'main-finalization'], + ], + ); + assert.deepEqual( + context.sessions.messages, + context.sessions.inputs.map((input) => input.userMessage), + ); + assert.deepEqual(context.sessions.inputs[0]?.config, context.sessions.inputs[3]?.config); + for (const input of context.sessions.inputs) { + assert.match(input.systemPrompt, /luowang-role-id: common/); + assert.doesNotMatch(input.userMessage, /luowang-role-id:/); + assert.doesNotMatch(input.systemPrompt, new RegExp(fixture.initialHead)); + assert.match(input.userMessage, new RegExp(fixture.initialHead)); + assert.equal( + input.customTools.some((tool) => tool.name === 'read'), + false, + ); + for (const version of input.roleInstructionVersions) { + assert.match(version.sha256, /^[a-f0-9]{64}$/); + assert.equal(version.applicationVersion, '0.1.0'); + assert.equal(version.formatVersion, '1'); + } + } + assert.match(context.sessions.inputs[0]?.systemPrompt ?? '', /luowang-role-id: main-planning/); + assert.doesNotMatch(context.sessions.inputs[0]?.systemPrompt ?? '', /runner-execution/); + assert.match( + context.sessions.inputs[1]?.systemPrompt ?? '', + /luowang-role-id: runner-execution/, + ); + assert.match(context.sessions.inputs[2]?.systemPrompt ?? '', /luowang-role-id: reviewer-audit/); + assert.match( + context.sessions.inputs[3]?.systemPrompt ?? '', + /luowang-role-id: main-finalization/, + ); + const mainToolContext = commandText( + await invokeTool(context.sessions.inputs[0] as AgentSessionInput, 'get_run_context', {}), + ); + const runnerToolContext = commandText( + await invokeTool(context.sessions.inputs[1] as AgentSessionInput, 'get_run_context', {}), + ); + assert.match(mainToolContext, /historyIssuesAvailable/); + assert.match(mainToolContext, /indexedReports/); + assert.doesNotMatch(runnerToolContext, /historyIssues|indexedReports|indexedScenarios/); + }); + + it('isolates repeated Main and Runner Sessions during initialization', async () => { + const fixture = await createGitFixture(); + const context = await createRunContext(fixture, ['passed', 'passed']); + + const result = await context.orchestrator.run({ + request: '初始化陌生项目的长期场景', + trigger: 'manual', + initialization: true, + }); + + assert.equal(result.status, 'completed', JSON.stringify(result)); + assert.deepEqual( + context.sessions.created, + ['main-a', 'runner', 'main-a', 'runner', 'reviewer', 'main-b'], + JSON.stringify(result), + ); + assert.equal(new Set(context.sessions.sessionObjects).size, 6); + assert.deepEqual( + context.sessions.inputs.map((input) => input.sessionKind), + [ + 'main-planning', + 'runner-execution', + 'main-planning', + 'runner-execution', + 'reviewer-audit', + 'main-finalization', + ], + ); + for (const index of [0, 2, 5]) { + assert.equal( + context.sessions.inputs[index]?.roleInstructionVersions.some( + (item) => item.id === 'scenario-initialization', + ), + true, + ); + assert.deepEqual(context.sessions.inputs[index]?.config, context.sessions.inputs[0]?.config); + } + for (const index of [1, 3, 4]) { + assert.equal( + context.sessions.inputs[index]?.roleInstructionVersions.some( + (item) => item.id === 'scenario-initialization', + ), + false, + ); + } + assert.deepEqual(context.sessions.inputs[1]?.config, context.sessions.inputs[3]?.config); + assert.notEqual(context.sessions.messages[0], context.sessions.messages[2]); + assert.notEqual(context.sessions.messages[1], context.sessions.messages[3]); }); it('rejects a second start while the first Run is still being prepared', async () => { @@ -79,7 +182,7 @@ describe('Phase 3 agent run', () => { assert.equal(completed?.status, 'completed', JSON.stringify(completed)); }); - it('passes read-only historical Issue context to Main A', async () => { + it('passes read-only historical Issue context to Main planning', async () => { const fixture = await createGitFixture(); const context = await createRunContext(fixture, ['passed']); context.repository.listIssues = async () => [ @@ -99,8 +202,17 @@ describe('Phase 3 agent run', () => { }); assert.equal(context.sessions.inputs[0]?.role, 'main-a'); - assert.match(context.sessions.inputs[0]?.systemPrompt ?? '', /历史登录问题/); - assert.match(context.sessions.inputs[0]?.systemPrompt ?? '', /historyIssuesAvailable/); + assert.doesNotMatch(context.sessions.inputs[0]?.systemPrompt ?? '', /历史登录问题/); + assert.match(context.sessions.inputs[0]?.userMessage ?? '', /历史登录问题/); + assert.match(context.sessions.inputs[0]?.userMessage ?? '', /historyIssuesAvailable/); + for (const input of context.sessions.inputs.slice(1)) { + assert.doesNotMatch(input.userMessage, /历史登录问题/); + assert.doesNotMatch(input.userMessage, /historyIssues/); + } + const runnerToolContext = commandText( + await invokeTool(context.sessions.inputs[1] as AgentSessionInput, 'get_run_context', {}), + ); + assert.doesNotMatch(runnerToolContext, /历史登录问题|historyIssues|indexedReports/); }); it('preserves failed and blocked result precedence from the independent report', async () => { @@ -245,6 +357,7 @@ async function createRunContext( configuration.updateRepository({ repository: fixture.remoteDir, scenarioBranch: 'scenario-testing', + scenarioMode: 'autonomous', }); configuration.updateHarness({ agents: { @@ -274,6 +387,7 @@ class RecordingSessionFactory implements AgentSessionFactory { readonly created: string[] = []; readonly disposed: string[] = []; readonly inputs: AgentSessionInput[] = []; + readonly messages: string[] = []; readonly sessionObjects: object[] = []; private outcomeIndex = 0; @@ -286,12 +400,20 @@ class RecordingSessionFactory implements AgentSessionFactory { this.created.push(input.role); this.inputs.push(input); const session = { - prompt: async () => { - if (input.role === 'main-a') { + prompt: async (message: string) => { + this.messages.push(message); + if (input.role === 'main-a' && hasTool(input, 'write_plan')) { await invokeTool(input, 'get_run_context', {}); await invokeTool(input, 'write_plan', { content: '# Plan\n\n无需场景测试:本次请求只验证文档事实,不影响产品行为。\n', }); + } else if (input.role === 'main-a') { + await invokeTool(input, 'read_run_artifact', { name: 'plan.md' }); + await invokeTool(input, 'read_run_artifact', { name: 'execution.md' }); + await invokeTool(input, 'read_run_artifact', { name: 'draft-report.md' }); + await invokeTool(input, 'write_scenario_patch', { + content: initializationScenarioPatch(), + }); } else if (input.role === 'runner') { await invokeTool(input, 'read_run_artifact', { name: 'plan.md' }); if (this.beforeReport) await this.beforeReport(); @@ -314,7 +436,7 @@ class RecordingSessionFactory implements AgentSessionFactory { content: '# Review\n\n独立确认无需场景测试:计划中的影响判断有依据。\n', }); } else { - const context = parsePromptContext(input.systemPrompt); + const context = parsePromptContext(input.userMessage); for (const name of ['plan.md', 'execution.md', 'draft-report.md', 'review.md']) { await invokeTool(input, 'read_run_artifact', { name }); } @@ -344,6 +466,10 @@ class RecordingSessionFactory implements AgentSessionFactory { } } +function hasTool(input: AgentSessionInput, name: string): boolean { + return input.customTools.some((tool) => tool.name === name); +} + async function invokeTool( input: AgentSessionInput, name: string, @@ -360,6 +486,52 @@ async function invokeTool( ) as Promise>>; } +function initializationScenarioPatch(): string { + const content = `--- +id: INIT-HOME-001 +name: 首页可访问 +description: 验证项目首页可访问 +status: approved +tags: + - core +--- + +## 目的 + +验证首页基础可用性。 + +## 前置条件 + +非生产环境可访问。 + +## 步骤 + +1. 打开首页。 + +## 期望 + +首页成功显示。 + +## 需要记录 + +状态码和页面标题。 +`; + const additions = content + .split('\n') + .slice(0, -1) + .map((line) => `+${line}`) + .join('\n'); + const lines = content.trimEnd().split('\n').length; + return `diff --git a/docs/scenario-testing/scenarios/INIT-HOME-001.md b/docs/scenario-testing/scenarios/INIT-HOME-001.md +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/docs/scenario-testing/scenarios/INIT-HOME-001.md +@@ -0,0 +1,${lines} @@ +${additions} +`; +} + function commandText(result: AgentToolResult>): string { return result.content.map((item) => ('text' in item ? item.text : '')).join(''); } @@ -371,16 +543,14 @@ function parsePromptContext(prompt: string): { targetCommit: string; includedCommits: string[]; } { - const match = prompt.match( - /固定 Run 上下文:\s*([\s\S]*?)\s*\n\s*必须|固定 Run 上下文:\s*([\s\S]*?)\s*\n\s*请|固定 Run 上下文:\s*([\s\S]*?)\s*\n\s*先/, - ); - const json = match?.[1] ?? match?.[2] ?? match?.[3]; + const match = prompt.match(/动态 Run 上下文:\s*([\s\S]+)$/); + const json = match?.[1]; assert.ok(json, 'missing prompt context'); return JSON.parse(json) as ReturnType; } function extractTarget(input: AgentSessionInput): string { - return parsePromptContext(input.systemPrompt).targetCommit; + return parsePromptContext(input.userMessage).targetCommit; } function reportFor( diff --git a/tests/phase4-orchestrator.test.ts b/tests/phase4-orchestrator.test.ts index 264c6d4..38b6ccc 100644 --- a/tests/phase4-orchestrator.test.ts +++ b/tests/phase4-orchestrator.test.ts @@ -70,7 +70,7 @@ describe('Phase 4 Run blocking boundaries', () => { assert.equal(result.result, 'passed', JSON.stringify(result)); }); - it('does not complete a Run when Main B writes a non-schema scenario result', async () => { + it('does not complete a Run when Main finalization writes a non-schema scenario result', async () => { const fixture = await createGitFixture(); const context = await createRunContext(fixture, 'malformed-report'); @@ -134,7 +134,7 @@ class FailureBoundarySessionFactory implements AgentSessionFactory { if (input.role === 'runner') { await invokeTool(input, 'read_run_artifact', { name: 'plan.md' }); - const context = parsePromptContext(input.systemPrompt); + const context = parsePromptContext(input.userMessage); if (this.mode === 'cleanup-failure') { await invokeTool(input, 'register_test_data', { id: 'luowang-test-user-1', @@ -174,7 +174,7 @@ class FailureBoundarySessionFactory implements AgentSessionFactory { for (const name of ['plan.md', 'execution.md', 'draft-report.md', 'review.md']) { await invokeTool(input, 'read_run_artifact', { name }); } - const context = parsePromptContext(input.systemPrompt); + const context = parsePromptContext(input.userMessage); await invokeTool(input, 'write_report', { content: this.mode === 'malformed-report' @@ -373,7 +373,7 @@ function parsePromptContext(prompt: string): { includedCommits: string[]; runDirectory: string; } { - const match = prompt.match(/固定 Run 上下文:\s*([\s\S]*?)\s*\n\s*(?:必须|请|先)/); + const match = prompt.match(/动态 Run 上下文:\s*([\s\S]+)$/); assert.ok(match?.[1], 'missing prompt context'); return JSON.parse(match[1]) as ReturnType; }