agents-tester.md 25.6 KB

Tester Agent · 完整 Skill 规范

Shell-stripped: preserves full prompt content for Agent system templates

L1 的对手方。读 Coder 的 code/ + spec + contract.json,写 vitest 单测,跑、收失败、产给 Coder 反思的 hint。同时桥接到 bugs.ndjson 做 bug 生命周期。

T

Tester Agent

写测试 · 跑测试 · 翻译失败原因 · 桥接 bugs.ndjson

Loop L1

Model Sonnet 4.6

Modes test · retry · resume

沙箱 units/{unitId}/ 内 tests/ + runs/{runId}/

会话复用 是(SessionPool key = tester.cid.pid.unitId)

① 功能需求

  1. 三种模式:test(Coder 完成后首次)/ retry(Coder 改过重跑)/ resume。
  2. 覆盖来源:
  3. spec.json.acceptance_criteria 每条 ≥ 1 case 3. contract.json.exports.http.responses 每个状态码 ≥ 1 case 4. 真跑:后端用 supertest 起真实 Express + better-sqlite3 :memory:;前端用 @vue/test-utils,不渲染浏览器。 5. 不允许 mock 整个被测模块。可 mock 外部 API / DB / 时间。 6. 失败必含 hint:给 Coder 下次反思的 1-2 句人话提示。禁止「fix the bug」「expected 401 got 500」式无信息废话。 7. 桥接 bugs.ndjson:失败 → bug_open append 一条 status=open;下次绿 → bug_transition(to='fixed')。 8. 幂等:同一代码反复跑结果一致。依赖时间 / 随机数 / 网络必须 stub。 9. 不写代码:Coder 的活。Tester 写 tests/ 才能算干活。

② Skill 设计

2.1 输出沙箱

data/companies/{cid}/projects/{pid}/units/{unitId}/ ├── code/ # 只读 (Coder 写) ├── contract.json # 只读 (Coder 写) ├── spec.json # 只读 (Architect 写) ├── coder.log # 只读 (Coder 写) ├── tests/ # Tester 写 ★ │ ├── *.spec.ts # vitest 测试 │ └── helpers/ # 共享 fixture (可选) └── runs/ # Tester 写 ★ └── {runId}/ ├── result.json # 结构化结果 ├── stdout.txt # vitest 原始输出 └── stderr.txt # crash 时 # 还要写 (跨项目): ../../bugs.ndjson # bug_open / bug_transition 工具 append

2.2 完整 System Prompt(可拷贝直用)

落 system/agents/devloop-tester/prompt.md。LoopController 注入时头部补 ## Memory + ## Unit Code + Contract(节选 code/ 的 export 接口 + contract.json)。 # ROLE 你是 LemonBoss DevLoop 的 Tester Agent。本次任务:测试 Unit {unitId}。 读 Coder 的 code/ + Architect 的 spec.json,写 vitest 单测,跑测试, 把失败翻译成给 Coder 下次反思的 hint。 # MODES - "test" Coder 首次完成,Tester 首次跑 - "retry" Coder 改了 code,重跑 (如有旧 tests 复用) - "resume" 从 resume_token 继续 # SANDBOX 你只能写: ./tests/** ./runs/{runId}/** 禁止写: ./code/** (Coder 的领地) ./contract.json (Coder 的领地) ./coder.log (Coder 的领地) ../{别的 unit}/** (其他 Unit) 你可读: ./code/** (你测的代码) ./contract.json (推导期望响应) ./spec.json (acceptance_criteria) ../{depends_on}/contract.json (跨 Unit 不读其 code) ./tests/** (复用旧 spec) # MANDATORY WORKFLOW ## step 0 · 读上下文 - 读 spec.json acceptance_criteria 全条 - 读 contract.json exports.http (每个 status code 必测) - 读 code/ 入口文件 (找 export 接口) - retry: 读 ./tests/* 看哪些已存在 (可保留 + 重跑) ## step 1 · write_todolist ### test 典型 (5 项): step-001 写 tests/{unit}.spec.ts - 后端: supertest + 内存 sqlite - 前端: @vue/test-utils mount step-002 覆盖 acceptance 全条 + contract 全 status code step-003 跑 vitest --reporter=json > runs/{runId}/raw.json step-004 parse_vitest → runs/{runId}/result.json + 生成 hint step-005 bug_open / bug_transition (按结果) ### retry 典型 (3 项): step-001 跑现有 tests (代码改了,测试可能不需要改) step-002 parse_vitest → result.json step-003 bug_transition (如之前 open 现绿,转 fixed) # RULES (硬约束) 1. 后端测试模板: import request from 'supertest'; import { describe, it, expect, beforeEach } from 'vitest'; import app from '../code/server'; // 用 :memory: sqlite 起干净状态 2. 前端测试模板: import { mount } from '@vue/test-utils'; import { describe, it, expect } from 'vitest'; import Component from '../code/Component.vue'; 3. 禁止 mock 整个被测模块。可以 mock: - 外部 API (fetch / axios) - 第三方服务 (S3 / 邮件) - 时间 (vi.useFakeTimers) - 随机 (vi.spyOn(Math, 'random')) 4. 每个 acceptance_criterion → 至少 1 个 it() block。 不允许把多条揉进一个 it()。 5. 每个 contract.responses 状态码 → 至少 1 个 negative + positive case。 401 → 测一个返 401 的输入 500 → 不主动测 (500 是 bug 不是 feature) 6. 失败 hint 必须是给 Coder 的 1-2 句**人话**提示: ✓ "user 不存在时不要 bcrypt.compare(null,...);先查再比" ✓ "title 字段缺失 req.body.title 是 undefined,先做存在性判断" ✗ "fix the bug" ✗ "expected 401 got 500" (没有信息量) 由 hint_builder 工具帮你从 stack + expected/got 生成 (但你要审一遍)。 7. bugs.ndjson 同步: 失败:调 bug_open({source:'unit_test', unit_id, run_id, test_name, ...}) 如该 unit 之前有 status=open 的同 test bug → 不重复 open (用 dedupKey) 绿:之前有 status=open/fixing → bug_transition(to='fixed') 8. 任何 Bash 带 JSON body → /tmp/*.json + -d @file (CLAUDE.md L91-99)。 9. 单次 vitest 跑总时长上限 60s。超时 → kill + 标 result.crashed=true。 10. 不写 ./code/, 不调 call_sub_agent。 # SELF-CHECK 1. tests/ 至少有 acceptance_criteria 数量 + contract status code 数量个 it() block 2. result.json 通过 typebox 校验 3. result.json.failures 每条有非空 hint 4. bugs.ndjson 新增/转换条目都在 5. todolist 全 done 6. 没写出沙箱

2.3 工具集

| 工具 | 签名 | 用途 | 访问边界 | | --- | --- | --- | --- | | Read | (path) | 读 code / contract / spec / 上游 contract / 历史 tests | 沙箱白名单 | | Write | (path, content) | 写 tests/ + runs/{runId}/ | tests/** + runs/** | | Bash | (cmd) | 跑 vitest run --reporter=json; npm i (限定包) | cwd 锁 units/{unitId}/, jsonGuard 强制, 60s 超时 | | parse_vitest NEW | (rawJsonPath) → RunResult | vitest JSON reporter → 结构化 result.json + 自动生成 hint | — | | hint_builder NEW | (failure) → string | 从 stack/expected/got 启发式生成 hint(Tester 可再编辑) | — | | bug_open NEW | (pid, bug) → bugId | append bugs.ndjson · dedupKey 防重 | bugs.ndjson | | bug_transition NEW | (pid, bugId, to, opts) | 状态机转换 | bugs.ndjson | | bug_list NEW | (pid, filter) | 查当前 open/fixing 的 bug(dedup 时用) | — | | write_todolist | (items) | 同上 | agent-state/ | | update_todo_item | (id, status) | 同上 | — | | update_runtime_state | (step, total) | 同上 | — | | timeline_append | (kind, ref, summary) | 同上 · kind: unit_tested · test_failed · test_passed | — | | record_memory | (topic, content, relevance) | memory/devloop/tester/ | memory/devloop/tester/ |

2.4 禁用

  • 不允许 Edit / Write code/ — Coder 领地
    • 不允许 mock 整个被测模块 — 否则测试永远绿
    • 不允许 inline JSON Bash — jsonGuard 强制
    • 不允许 call_sub_agent
    • 不允许跳过 hint 字段 — failure 必含可执行提示

    ③ 持久化集成

    同 Coder 模式 + 加 bugs.ndjson 桥接。失败时 bug_open;绿时 bug_transition;中间状态由 LoopController 转 fixing。

    Tester memory 典型话题(很少):

    • "vitest 启动慢 (Vite cold start),固定用 --pool=threads 加速"
    • "better-sqlite3 :memory: 跨 it() 状态隔离需 beforeEach 重建"
    • memory 在 Tester 这层很少有可复用洞见,注意不要乱写

    ④ I/O 契约

    const TesterInput = Type.Object({
      pid:        Type.String(),
      unit_id:    Type.String(),
      mode:       Type.Union([Type.Literal('test'),
                              Type.Literal('retry'),
                              Type.Literal('resume')]),
      resume_token: Type.Optional(Type.String()),
      call_id:    Type.String(),
      company_id: Type.String(),
    });
    
    const TesterOutput = Type.Object({
      ok:           Type.Boolean(),         // 总判:全绿才 true
      mode:         Type.String(),
      unit_id:      Type.String(),
      run_id:       Type.String(),
      result_ref:   Type.String(),         // path to result.json
    
      summary:      Type.Object({
        total:   Type.Number(),
        passed:  Type.Number(),
        failed:  Type.Number(),
        skipped: Type.Number(),
      }),
    
      failures:     Type.Array(Type.Object({
        test:     Type.String(),
        expected: Type.Any(),
        got:      Type.Any(),
        stack:    Type.String(),
        hint:     Type.String(),        // 非空,给 Coder
        bug_id:   Type.String(),       // 关联 bugs.ndjson
      })),
    
      bugs_opened:      Type.Array(Type.String()),  // 新开 bug id 列表
      bugs_transitioned: Type.Array(Type.Object({ id, from, to })),
    
      acceptance_coverage: Type.Number({ minimum:0, maximum:1 }),
      contract_coverage:   Type.Number({ minimum:0, maximum:1 }),
    
      crashed:      Type.Boolean(),         // vitest 自身崩
      duration_ms:  Type.Number(),
      tokens_used:  Type.Number(),
    });
    

    ⑤ 上下游传递

    ⬆ 上游

    • Coder · 通过 code/ + contract.json
    • Architect · 通过 spec.json acceptance + sdd tests 列表
    • LoopController · 注入 input + memory

    ⬇ 下游

    • Coder(retry 时)· 读 result.json.failures.hint 反思
    • LoopController · 读 result.ok 决定 pass/retry/block
    • UnitRegistry · 接 status: testing → passed | failed
    • BugTracker / bugs.ndjson · 接 bug_open / bug_transition
    • Integrator(passed 后)· 间接:tests/ 可作 e2e 回归基础
    • Dashboard · SSE stream 看 stdout

    ⑥ 完整执行样例

    Sample A · test 首跑红 1 条 Sample B · retry 转绿 Sample C · crashed (vitest 自崩)

    # ─────────── INPUT ───────────
    {
      "pid": "p-test001", "unit_id": "todo-be",
      "mode": "test", "call_id": "call-t-001", ...
    }
    
    # INJECTED Unit Code Excerpt (LoopController 拼)
    ## Unit Code: todo-be
    exports from code/server.js:    app (Express)
    exports from code/service.js:   create(title), list()
    
    ## Contract (contract.json)
    POST /api/todos { title } → 201 | 400
    GET  /api/todos          → 200 { items }
    
    ## Acceptance Criteria (spec.json)
    - 创建后能列出
    - 空 title 返 400
    - 200+ 字符 title 返 400
    
    # step 1 · write_todolist (5 项 test 模板)
    
    # step 2 · 写 tests/todo-be.spec.ts
    import request from 'supertest';
    import { describe, it, expect, beforeEach } from 'vitest';
    
    // 注意:Coder 实现里 db 是 module 级 :memory:,跨 it() 状态会污染
    // 这里 fresh app/db 每个 it() (后面 hint 会指出 Coder 该改)
    
    describe('todo-be POST /api/todos', () => {
      let app;
      beforeEach(() => {
        delete require.cache[require.resolve('../code/server')];
        app = require('../code/server');
      });
      it('有效 title → 201 + 返 id', async () => {
        const r = await request(app).post('/api/todos').send({ title: 'buy milk' });
        expect(r.status).toBe(201);
        expect(r.body.id).toBeTypeOf('number');
      });
      it('空 title → 400 title_required', async () => {
        const r = await request(app).post('/api/todos').send({ title: '' });
        expect(r.status).toBe(400);
        expect(r.body.error).toBe('title_required');
      });
      it('缺 body title → 400', async () => {
        const r = await request(app).post('/api/todos').send({});
        expect(r.status).toBe(400);
      });
      it('200+ title → 400 title_too_long', async () => {
        const r = await request(app).post('/api/todos')
          .send({ title: 'x'.repeat(201) });
        expect(r.status).toBe(400);
      });
    });
    
    describe('todo-be GET /api/todos', () => {
      it('创建后能列出', async () => {
        const app = require('../code/server');
        await request(app).post('/api/todos').send({ title: 'a' });
        const r = await request(app).get('/api/todos');
        expect(r.status).toBe(200);
        expect(r.body.items.some(t => t.title === 'a')).toBe(true);
      });
    });
    
    # step 3 · Bash: vitest run --reporter=json > runs/run-20260513-1031/raw.json
    
    # step 4 · parse_vitest
    parse_vitest("runs/run-20260513-1031/raw.json")
    // 结果: 5 个 test, 4 passed, 1 failed
    // 失败: "缺 body title → 400" 实际收 500 TypeError
    
    # hint_builder 看 stack: TypeError on req.body.title.length
    hint_builder({
      expected: 400, got: 500,
      stack: "TypeError: cannot read properties of undefined (reading 'length') at create..."
    })
    // → "缺 body 字段时 req.body.title 是 undefined,先做存在性判断再 .length"
    
    # step 5 · bug_open
    bug_open({
      source: 'unit_test', unit_id: 'todo-be', run_id: 'run-20260513-1031',
      test_name: '缺 body title → 400',
      category: 'unknown', severity: 'high',
      title: '缺 body title 时 500 而非 400',
      detail: 'TypeError: ...', affected_units: ['todo-be']
    })
    // → bug-20260513-1031-c9f2  (新开)
    
    # 写 result.json
    {
      "run_id": "run-20260513-1031", "loop": "L1",
      "unit_id": "todo-be", "agent": "tester", "attempt": 1,
      "ok": false,
      "summary": {"total":5,"passed":4,"failed":1},
      "failures": [{
        "test": "缺 body title → 400",
        "expected": 400, "got": 500,
        "stack": "TypeError: ...",
        "hint": "缺 body 字段时 req.body.title 是 undefined,先做存在性判断再 .length",
        "bug_id": "bug-20260513-1031-c9f2"
      }],
      "next_action": "retry_coder",
      "retry_budget_left": 4,
      "duration_ms": 1_245
    }
    
    # OUTPUT
    {
      "ok": false, "mode": "test", "unit_id": "todo-be",
      "run_id": "run-20260513-1031",
      "summary": {"total":5,"passed":4,"failed":1,"skipped":0},
      "failures": [{ "hint": "...", "bug_id": "bug-..." }],
      "bugs_opened": ["bug-20260513-1031-c9f2"],
      "bugs_transitioned": [],
      "acceptance_coverage": 1.0,   // 3/3
      "contract_coverage": 1.0,
      "crashed": false,
      "duration_ms": 12_410, "tokens_used": 4_120
    }
    
    # LoopController 接 ok=false → 触发 Coder retry (带 history)
    
    # ─────────── INPUT (retry mode) ───────────
    {
      "pid": "p-test001", "unit_id": "todo-be",
      "mode": "retry", "call_id": "call-t-002", ...
    }
    
    # 上下文: Coder 已改 code/server.js (按上次 hint)
    # tests/ 不需要改,直接复用
    
    # step 1 · todolist 3 项 (retry 模板)
    
    # step 2 · Bash vitest 重跑
    # step 3 · parse_vitest
    //  5 passed, 0 failed
    
    # step 4 · bug_list(unit_id=todo-be, status=[open,fixing]) → 1 条:bug-..-c9f2
    #         之前 LoopController 让 Coder 干活时已转 open → fixing
    #         现在 ok=true → 转 fixing → fixed
    bug_transition(pid, "bug-20260513-1031-c9f2", {
      to: "fixed", by: "tester",
      fix_refs: ["run-20260513-1034"]
    })
    
    # OUTPUT
    {
      "ok": true, "mode": "retry", "unit_id": "todo-be",
      "run_id": "run-20260513-1034",
      "summary": {"total":5,"passed":5,"failed":0},
      "failures": [],
      "bugs_opened": [],
      "bugs_transitioned": [{ "id":"bug-...c9f2", "from":"fixing", "to":"fixed" }],
      ...
    }
    
    # LoopController 接 ok=true → Unit 转 passed → 等 L2 闸门
    
    # ─────────── INPUT (vitest 自身崩) ───────────
    {
      "pid": "p-test001", "unit_id": "profile-be",
      "mode": "test", ...
    }
    
    # step 3 · Bash vitest run → 退出码 1, 但不是测试失败
    #         vitest 启动时 require code/server.js 直接抛 SyntaxError
    
    # step 4 · parse_vitest 检测 raw.json 不存在或字段缺
    #         → result.crashed = true
    #         → next_action = "retry_tester" 而非 "retry_coder"
    
    # 写 result.json
    {
      "run_id": "run-...", "unit_id": "profile-be",
      "ok": false, "crashed": true,
      "crash_reason": "vitest could not load ../code/server.js: SyntaxError",
      "stdout_tail": "...完整 stderr...",
      "next_action": "retry_tester"     // 注意:不是 retry_coder
    }
    
    # bug_open 也开,但标 source='unit_test_infra' 区分
    # LoopController 接 crashed=true → retry Tester (而非 Coder)
    #                                 → retry 仍 crash → 升级 Coder retry
    

    ⑦ 助手工具源码

    7.1 parse-vitest-json.js · vitest JSON reporter 解析

    // server/services/agent-tools/parse-vitest-json.js · ~80 行
    const fs   = require('fs');
    const path = require('path');
    const { buildHint } = require('./hint-builder');
    
    function parseVitest(rawJsonPath, opts = {}) {
      if (!fs.existsSync(rawJsonPath)) {
        return {
          ok: false, crashed: true,
          crash_reason: 'vitest raw json missing - process likely crashed early',
          summary: { total:0, passed:0, failed:0, skipped:0 },
          failures: [],
        };
      }
    
      let raw;
      try {
        raw = JSON.parse(fs.readFileSync(rawJsonPath, 'utf8'));
      } catch (e) {
        return { ok:false, crashed:true, crash_reason: 'invalid vitest json', summary:{}, failures:[] };
      }
    
      const summary = { total:0, passed:0, failed:0, skipped:0 };
      const failures = [];
    
      // vitest 1.x JSON reporter 结构:
      //   { testResults: [{ assertionResults: [{ status, fullName, failureMessages, ... }] }] }
      for (const file of (raw.testResults || [])) {
        for (const a of (file.assertionResults || [])) {
          summary.total++;
          if (a.status === 'passed')  summary.passed++;
          else if (a.status === 'failed') {
            summary.failed++;
            const stack = (a.failureMessages || []).join('\n');
            const { expected, got } = extractAssertion(stack);
            failures.push({
              test: a.fullName,
              expected, got, stack,
              hint: buildHint({ testName: a.fullName, expected, got, stack }),
            });
          }
          else summary.skipped++;
        }
      }
    
      return {
        ok: summary.failed === 0 && summary.total > 0,
        crashed: false,
        summary, failures,
        duration_ms: raw.testResults?.[0]?.perfStats?.runtime || 0,
      };
    }
    
    function extractAssertion(stack) {
      const exp = stack.match(/Expected: ([^\n]+)/);
      const got = stack.match(/Received: ([^\n]+)/);
      return {
        expected: exp ? exp[1].trim() : null,
        got:      got ? got[1].trim() : null,
      };
    }
    
    module.exports = { parseVitest };
    

    7.2 hint-builder.js · 失败 → 人话 hint

    // server/services/agent-tools/hint-builder.js · ~60 行
    
    const PATTERNS = [
      {
        match: (s) => /cannot read.*?of undefined/i.test(s),
        hint:  (ctx) => '某个对象/字段是 undefined,先做存在性判断再访问',
      },
      {
        match: (s) => /expected (\d+).*received (\d+)/i.test(s),
        hint:  (ctx) => {
          const m = ctx.stack.match(/expected (\d+).*received (\d+)/i);
          const exp = +m[1], got = +m[2];
          if (exp === 401 && got === 500)
            return '凭据错误应返 401 不是 500;检查认证路径是否 try/catch 兜底';
          if (exp === 400 && got === 500)
            return '参数校验错应返 400 不是 500;在 route 层检查后再传 service';
          if (exp === 404 && got === 200)
            return '不存在的资源不应返 200;返回前判断查询结果';
          return `期望 ${exp} 收 ${got},检查对应分支处理`;
        },
      },
      {
        match: (s) => /TypeError.*compare/i.test(s),
        hint:  (ctx) => '调用 bcrypt.compare 前先确认 user 存在;否则 null 参数会抛 TypeError',
      },
      {
        match: (s) => /SQLITE_CONSTRAINT/i.test(s),
        hint:  (ctx) => '数据库约束冲突(unique/foreign key),业务层应该先查再写',
      },
      {
        match: (s) => /timeout/i.test(s),
        hint:  (ctx) => '某个异步操作超时;检查是否漏 await 或外部依赖未 mock',
      },
    ];
    
    function buildHint(ctx) {
      const stack = ctx.stack || '';
      for (const p of PATTERNS) {
        if (p.match(stack)) return p.hint(ctx);
      }
      // fallback: Tester 在 prompt 里被要求人工审一遍补充
      return `${ctx.testName}: 期望 ${ctx.expected} 收 ${ctx.got},检查 stack 推断原因 (Tester 应补充)`;
    }
    
    module.exports = { buildHint, PATTERNS };
    

    7.3 bug-bridge.js · Tester ↔ bugs.ndjson

    // server/services/agent-tools/bug-bridge.js · ~70 行
    const fs   = require('fs');
    const path = require('path');
    const { bugTracker } = require('../bug-tracker');
    
    /**
     * Tester 收到 vitest failures 后,自动桥接到 bugs.ndjson
     * - dedup: 同 unit 同 test 已有 open/fixing → 复用 id
     * - 新 fail → bug_open
     * 调用方:Tester Agent 的工具调用 (而非直接 fs)
     */
    function bridgeFailuresToBugs(pid, unitId, runId, failures) {
      const bugs_opened = [];
      const existing = bugTracker.list(pid, {
        unit_id: unitId,
        status: ['open', 'fixing'],
      });
    
      for (const f of failures) {
        const dedupKey = `${unitId}:${f.test}`;
        const dup = existing.find(b => bugTracker.dedupKey(b) === dedupKey);
    
        if (dup) {
          f.bug_id = dup.id;   // 复用,不新开
        } else {
          const bug = bugTracker.open(pid, {
            source: 'unit_test',
            unit_id: unitId, run_id: runId,
            test_name: f.test,
            category: 'unknown',    // BugTriager 在 L2 才精修
            severity: guessSeverity(f),
            title: `${f.test}: 期望 ${f.expected} 收 ${f.got}`,
            detail: f.stack,
            affected_units: [unitId],
          });
          f.bug_id = bug.id;
          bugs_opened.push(bug.id);
        }
      }
    
      return { bugs_opened };
    }
    
    function bridgePassToBugs(pid, unitId, runId) {
      // 单测绿了 → 该 unit 所有 open/fixing 转 fixed
      const existing = bugTracker.list(pid, {
        unit_id: unitId, status: ['open', 'fixing'],
      });
      const transitioned = [];
      for (const b of existing) {
        bugTracker.transition(pid, b.id, {
          to: 'fixed', by: 'tester',
          fix_refs: [runId],
        });
        transitioned.push({ id: b.id, from: b.status, to: 'fixed' });
      }
      return { bugs_transitioned: transitioned };
    }
    
    function guessSeverity(f) {
      if (/5\d\d/.test(String(f.got))) return 'high';
      if (f.test.includes('security') || f.test.includes('auth')) return 'critical';
      return 'medium';
    }
    
    module.exports = { bridgeFailuresToBugs, bridgePassToBugs };
    

    ⑧ 自检 Checklist

    # 检查项 命令 / 工具 通过判定
    1 tests/ 至少含 N 个 it()(N = acceptance + contract responses) cheerio-like scan true
    2 result.json 通过 typebox schema check (M-7) 合规
    3 每个 failure 有非空 hint jsonpath 无空 hint
    4 bugs.ndjson 同步 对照 bug_open/transition 输出 bugs_opened/transitioned 全部有 entry
    5 沙箱无越界 scanSandbox violations=[]
    6 没 mock 整个被测模块 spec 文件 grep vi.mock('../code/' 无
    7 todolist 全 done read agent-state/tester-todo.json true
    8 timeline 含 unit_tested / test_failed / test_passed tail 命中
    9 vitest 没超时(60s) duration_ms true
    10 acceptance_coverage = 1.0 compare spec.json acceptance vs tests it() 名称 1.0

    ⑨ 失败模式与降级

    场景 检测 降级
    vitest 自身崩 parse_vitest crashed=true result.crashed=true · next_action='retry_tester'
    Tester 写 trivial test(永远绿) spec 强制至少 1 negative case Architect 在 spec.json 加 negative criterion
    Tester 越权改 code/ 沙箱拒绝 —
    vitest 超时(>60s) Bash 工具超时 kill + result.crashed=true · 排查死循环测试
    hint 为空(只 fallback) self-check 4 Tester 必须人工审 + 补充
    npm install 慢 同 Coder 缓存 node_modules 共享
    测试运行依赖网络(fetch real API) vitest spec 扫描 禁止 · 必须 mock fetch
    vitest 版本与 LemonBoss 不一致 package.json 校验 用 LemonBoss 既有 vitest 版本

    ⑩ 与其他 Agent 握手摘要

    对方 Tester 给的 Tester 收的
    Coder tests/* + result.json.failures (含 hint) code/** + contract.json
    Architect (无) spec.json acceptance + sdd tests 列表
    Integrator tests/ 可复用作回归基础 (无)
    BugTracker bug_open / bug_transition —
    LoopController TesterOutput JSON TesterInput
    UnitRegistry — —(LoopController 中介)
    memory/devloop/tester/ 很少写 启动注入

    ⑪ 实战验证命令

    # 前置: Coder 已跑通,units/todo-be/code/* + contract.json 就绪
    
    # 1. 注册模板
    #    system/agents/devloop-tester/prompt.md ← 本页 §2.2
    
    # 2. 准备输入
    echo '{"pid":"p-test001","unit_id":"todo-be","mode":"test","call_id":"call-t-1","company_id":"c-test"}' > /tmp/tester-test.json
    
    # 3. 调 Tester
    curl -X POST http://localhost:3010/api/devloop/_test/run-agent/tester -d @/tmp/tester-test.json
    
    # 4. 期望:30-60 秒内
    #    units/todo-be/tests/*.spec.ts 生成
    #    units/todo-be/runs/{runId}/result.json 含 summary + failures (如有)
    
    # 5. 验证
    ls data/companies/c-test/projects/p-test001/units/todo-be/tests/
    # 期望: 至少 1 个 .spec.ts
    
    cat data/companies/c-test/projects/p-test001/units/todo-be/runs/*/result.json | jq .summary
    # 期望: { total: ≥4, passed/failed 看 Coder 代码质量 }
    
    cat data/companies/c-test/projects/p-test001/bugs.ndjson | head -5
    # 期望: 若有 failure,每条 failure 对应 1 行 bug 记录 (status=open)
    
    # 6. 模拟 Coder 修后 retry
    echo '{"pid":"p-test001","unit_id":"todo-be","mode":"retry","call_id":"call-t-2","company_id":"c-test"}' > /tmp/tester-retry.json
    curl -X POST http://localhost:3010/api/devloop/_test/run-agent/tester -d @/tmp/tester-retry.json
    
    # 期望:
    #   result.json ok=true (Coder 已根据 hint 改了)
    #   bugs.ndjson 末尾新增一行 (同 id, status=fixed)
    node -e "
    const {bugTracker} = require('./server/services/bug-tracker');
    console.log(bugTracker.list('p-test001'));
    "
    # 期望: 全部 bug 状态 = fixed
    

    Coder + Tester 这两 §⑪ 都通过 = L1 完整闭环验证完成。Coder/Tester 反复跑直到 unit passed,这是整个 DevLoop 最高频运行的循环。