Commit 507ec3fae5169ad7dce2835936bd72eaeaf76e2e
1 parent
83afecbf
fix(agent-body): work 节点补齐 soul/tools/identity 等身份上下文,org node 角色定义截断改为可见
## 问题 1:work 节点只有 agent.md 进提示词 graph-engine 驱动 work 节点走 PromptBuilder.build(),只读 protocol + agent.md + companyContextPath + user.md/memory.md/近期记忆。交互式的 agent-chat.js (buildSystemPrompt)另有一条独立实现,会读 soul.md/identity.md/tools.md/ heartbeat.md/OKR 目标/hippocampus 检索/main_skill/SKILL.md/其它 skills——两条链路 从未同步过。静默到什么程度:agent 照样跑、照样成功,只是少了人格/工具约束/长期 记忆检索,没有任何报错能看出原因。 新增 identity-context.js,抽出 agent-chat 那份逻辑里「读哪些文件」的等价实现(不 读 agent.md/user.md/memory.md/近期记忆——那五样已经被 PromptBuilder 覆盖,避免 重复),接进 PromptBuilder.build() 的非 SKILL.md 分支(scoped skill 那类不是完整 身份,不适用)。用户明确选择「全量对齐」:soul/identity/tools/heartbeat/OKR/ hippocampus/skills 一次性全补,不做裁剪版。 ponytail:没有反过来改 agent-chat.js 去调用这份共享实现——两处现在是「同构但 独立」的两份代码。agent-chat.js 是长期稳定的高频路径,为这次修复去动它风险和 收益不成比例,见 identity-context.js 头注的升级路径。 ## 问题 2:org node 角色定义截断到 2000 字,切没切完全不可见 describeRoleDefinition 给 workgraph-planning 的角色定义摘录一直硬切前 2000 字 (有意为之,规划要的是「职能」不是全文)。但切没切、切掉了多少,规划 agent 一无所知——agent.md 写得长一点、职能说明排在后半段,这句关键描述可能根本没 进提示词,且没有任何信号。截断上限不变,改成截断时附一句 ⚠️ 提示 + 完整路径, 给它一条自己去读全文的退路。 ## 测试 identity-context.test.js(10 条):文件读取的每个条件分支、OKR/hippocampus 只在有 agentId 时才查、OKR 抛错不拖累其它内容、skills 目录扫描排除 main_skill。 两个依赖(OkrService/retrieveRelevantPlans)用直接换 CJS module.exports 属性 的手法隔离——本仓库服务层是纯 CommonJS,vi.mock 对嵌套 require() 链路不生效 (实测踩过),且这两个依赖没有 env 覆盖点,真实路径不能进单测。 describe-role-definition-truncation.test.js(3 条):截断/不截断/本体不存在 三种情况,用真实 system/agents/ 下的 zz- 前缀临时本体(locator.bodyDir 没有 env 覆盖点,只能顺着真实路径走),afterEach 清理不留痕迹。 全量 npx vitest run:150 文件 / 1378 用例,零回归。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
5 changed files
with
337 additions
and
2 deletions
server/services/graph-engine.js
| ... | ... | @@ -919,8 +919,17 @@ function describeRoleDefinition(ctx) { |
| 919 | 919 | for (const f of candidates) { |
| 920 | 920 | try { |
| 921 | 921 | if (!fs.existsSync(f)) continue; |
| 922 | - // 只给开头一段:规划要的是「这个节点负责什么职能」,不是它的完整工作流 | |
| 923 | - return `- 角色定义摘录(${path.relative(REPO_ROOT, f)}):\n${fs.readFileSync(f, 'utf8').slice(0, 2000)}`; | |
| 922 | + // 只给开头一段:规划要的是「这个节点负责什么职能」,不是它的完整工作流。 | |
| 923 | + // 但截断必须**可见**——原来悄悄切掉后半段,agent.md 写得长一点(常见于职能说明 | |
| 924 | + // 排在文件后半的本体),「这个节点负责什么职能」这句关键描述就可能根本没进提示词, | |
| 925 | + // 而 workgraph-planning 拿不到信号、只会按看到的那半截自行推断,不会知道自己漏看了。 | |
| 926 | + // 加一句提示 + 完整路径,给它一个自己去读全文的退路,不是把上限拉大(2000 仍是故意的)。 | |
| 927 | + const full = fs.readFileSync(f, 'utf8'); | |
| 928 | + const excerpt = full.slice(0, 2000); | |
| 929 | + const truncNote = full.length > 2000 | |
| 930 | + ? `\n(⚠️ 原文共 ${full.length} 字,已截断到前 2000 字——如果上面没读到「这个节点负责什么职能」,说明它在后半段,去读完整文件 ${f})` | |
| 931 | + : ''; | |
| 932 | + return `- 角色定义摘录(${path.relative(REPO_ROOT, f)}):\n${excerpt}${truncNote}`; | |
| 924 | 933 | } catch { /* 读不动就换下一个候选 */ } |
| 925 | 934 | } |
| 926 | 935 | return '- 角色定义:未找到该本体的 agent.md,请按节点名推断职能,并在规划记录里写明这一项缺失。'; |
| ... | ... | @@ -4379,6 +4388,9 @@ module.exports = { |
| 4379 | 4388 | // W9:失败上下文段的构造与它的数据源。describeFailureContext 的真实调用方是 advanceOne, |
| 4380 | 4389 | // 导出只是让单测能不起真 CLI 直接验这一段文本(同 ensureProjectRoot / diffRepoStatus 的口径)。 |
| 4381 | 4390 | describeFailureContext, |
| 4391 | + // 「本体装配静默坑」修复:截断可见性只有一条判据(截断了就必须带⚠️提示), | |
| 4392 | + // 导出给单测直接验,同 describeFailureContext 的口径。 | |
| 4393 | + describeRoleDefinition, | |
| 4382 | 4394 | LOOP_HARD_CAP_EDGE, |
| 4383 | 4395 | LOOP_HARD_CAP_RUN, |
| 4384 | 4396 | }; | ... | ... |
server/src/core/identity-context.js
0 → 100644
| 1 | +/** | |
| 2 | + * 本体身份上下文(soul/identity/tools/heartbeat/OKR/hippocampus/skills)的共用读法。 | |
| 3 | + * | |
| 4 | + * ── 修的是哪个坑 ────────────────────────────────────────────────────────── | |
| 5 | + * graph-engine 驱动的 work 节点(`agent_call`)此前只有 `agent.md` 进提示词—— | |
| 6 | + * `PromptBuilder.build()` 走的是「protocol + agent.md + companyContextPath + | |
| 7 | + * user.md/memory.md/近期记忆」这一条链路,`soul.md`/`identity.md`/`tools.md`/ | |
| 8 | + * `heartbeat.md`/OKR 目标/hippocampus 检索/skills 全部缺失。而交互式的 | |
| 9 | + * `agent-chat.js`(`buildSystemPrompt()`)一直都在读这些文件,两条链路从未同步过。 | |
| 10 | + * 静默到什么程度:agent 照样跑、照样成功,只是少了人格/工具约束/长期记忆检索, | |
| 11 | + * 表现为「work 节点里的 agent 比 chat 里蠢一截」,没有任何报错或日志能看出原因。 | |
| 12 | + * | |
| 13 | + * ── 这里只补缺的,不重复已有的 ──────────────────────────────────────────── | |
| 14 | + * `agent.md` 由 `PromptBuilder.getAgentPrompt()` 已经读过;`user.md`/`memory.md`/ | |
| 15 | + * 近期记忆由 `PromptBuilder.buildCompanyStateParts()` 已经读过——这个函数**不重复** | |
| 16 | + * 那五样,只读 agent-chat 有、PromptBuilder 没有的那几样。 | |
| 17 | + * | |
| 18 | + * ── ponytail:没有让 agent-chat.js 反过来调用这份实现 ───────────────────── | |
| 19 | + * 两处现在是「同构但独立」的两份代码,不是真正共享一份——`buildSystemPrompt()` | |
| 20 | + * 是长期稳定的高频路径,为了这次修复去动它,风险和收益不成比例。真要收敛成 | |
| 21 | + * 一份实现,得先给 `buildSystemPrompt()` 补一圈契约测试再动。升级路径:有余力 | |
| 22 | + * 时把 agent-chat.js 里对应的读文件片段也换成调这份实现,届时可以直接删掉 | |
| 23 | + * agent-chat.js 里那几段、只保留它自己特有的东西(company 环境上下文 / 运行时坐标 | |
| 24 | + * 声明 / userMessage 相关的 hippocampus 搜索文本拼接)。 | |
| 25 | + */ | |
| 26 | +const fs = require('fs'); | |
| 27 | +const path = require('path'); | |
| 28 | +const { OkrService } = require('../../services/okr-service'); | |
| 29 | +const { retrieveRelevantPlans } = require('../../services/memory-service'); | |
| 30 | + | |
| 31 | +// 与 agent-chat.js 同款用法:全局 OKR 索引(不按公司分库),module 级单例。 | |
| 32 | +const okrService = new OkrService(); | |
| 33 | + | |
| 34 | +function readTrim(p) { | |
| 35 | + try { return fs.readFileSync(p, 'utf-8').trim(); } catch { return ''; } | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** | |
| 39 | + * @param {object} a | |
| 40 | + * @param {string} a.bodyDir 本体目录(agent.md/soul.md/.claude/ 所在,只读) | |
| 41 | + * @param {string} [a.agentId] OKR/hippocampus 检索用的身份键;没有则跳过这两段 | |
| 42 | + * @param {string} [a.searchText=''] hippocampus 检索用的当前任务文本(agent-chat 用的是 | |
| 43 | + * userMessage + todo.md,这里用调用方传入的任务描述) | |
| 44 | + * @returns {string[]} 按顺序排好的提示词分段,调用方自己 push 进 parts 数组 | |
| 45 | + */ | |
| 46 | +function buildIdentityContextParts({ bodyDir, agentId, searchText = '' }) { | |
| 47 | + const parts = []; | |
| 48 | + if (!bodyDir) return parts; | |
| 49 | + | |
| 50 | + // soul.md / identity.md / tools.md —— agent.md 已经由调用方读过,这里不重复 | |
| 51 | + for (const f of ['soul.md', 'identity.md', 'tools.md']) { | |
| 52 | + const c = readTrim(path.join(bodyDir, f)); | |
| 53 | + if (c) parts.push(c); | |
| 54 | + } | |
| 55 | + | |
| 56 | + const heartbeat = readTrim(path.join(bodyDir, 'heartbeat.md')); | |
| 57 | + if (heartbeat) parts.push(heartbeat); | |
| 58 | + | |
| 59 | + if (agentId) { | |
| 60 | + try { | |
| 61 | + const goalContext = okrService.getActiveContextForAgent(agentId); | |
| 62 | + if (goalContext && goalContext.trim()) parts.push(goalContext.trim()); | |
| 63 | + } catch (err) { | |
| 64 | + // eslint-disable-next-line no-console | |
| 65 | + console.error(`[IdentityContext] ⚠️ 读取目标上下文失败: ${err.message}`); | |
| 66 | + } | |
| 67 | + | |
| 68 | + // user.md / memory.md / 近期记忆已经由 PromptBuilder.buildCompanyStateParts 读过, | |
| 69 | + // 不在这里重复;hippocampus 检索原实现里跟那三样同一批读,但 PromptBuilder 没有 | |
| 70 | + // 对应覆盖,仍然缺,补上。 | |
| 71 | + const hippocampusPlans = retrieveRelevantPlans(agentId, searchText); | |
| 72 | + if (hippocampusPlans) parts.push(hippocampusPlans); | |
| 73 | + } | |
| 74 | + | |
| 75 | + const mainSkill = readTrim(path.join(bodyDir, '.claude/skills/main_skill/SKILL.md')); | |
| 76 | + if (mainSkill) parts.push('---\n\n# 我的核心主技能 (执行准则)\n\n' + mainSkill); | |
| 77 | + | |
| 78 | + const localLessons = readTrim(path.join(bodyDir, 'workspace/_lessons.md')); | |
| 79 | + if (localLessons) { | |
| 80 | + parts.push('---\n\n## ⚠️ 本地试错防坑经验 (_lessons.md)\n不要重复踩下面的坑,仔细阅读并绕开:\n\n' + localLessons); | |
| 81 | + } | |
| 82 | + | |
| 83 | + const skillsMd = readTrim(path.join(bodyDir, '.claude/skills/skills.md')); | |
| 84 | + if (skillsMd) parts.push(skillsMd); | |
| 85 | + | |
| 86 | + // 扫描除 main_skill 外的所有 skill 子目录 | |
| 87 | + const skillsDir = path.join(bodyDir, '.claude', 'skills'); | |
| 88 | + if (fs.existsSync(skillsDir)) { | |
| 89 | + for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { | |
| 90 | + if (!entry.isDirectory() || entry.name === 'main_skill') continue; | |
| 91 | + const c = readTrim(path.join(skillsDir, entry.name, 'SKILL.md')); | |
| 92 | + if (c) parts.push(`---\n\n## 依赖技能: ${entry.name}\n\n${c}`); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + return parts; | |
| 97 | +} | |
| 98 | + | |
| 99 | +module.exports = { buildIdentityContextParts }; | ... | ... |
server/src/core/prompt-builder.js
| ... | ... | @@ -5,6 +5,7 @@ const fs = require('fs'); |
| 5 | 5 | const path = require('path'); |
| 6 | 6 | const { companyRootDir } = require('../../services/company-paths'); |
| 7 | 7 | const { loadRecentMemory } = require('../../services/memory-service'); |
| 8 | +const { buildIdentityContextParts } = require('./identity-context'); | |
| 8 | 9 | |
| 9 | 10 | /** |
| 10 | 11 | * 交付物的唯一归宿:公司共享 workspace(28 §4.4)。 |
| ... | ... | @@ -287,6 +288,16 @@ class PromptBuilder { |
| 287 | 288 | // 与 getAgentPrompt / buildCompanyStateParts 是同一条理由。protocol 才是仓库里的静态文件。 |
| 288 | 289 | parts.push(fs.readFileSync(agent.companyContextPath, 'utf-8')); |
| 289 | 290 | } |
| 291 | + // 「本体装配静默坑」修复:soul.md/identity.md/tools.md/heartbeat.md/OKR 目标/ | |
| 292 | + // hippocampus 检索/skills——agent-chat.js 一直在读,这条链路此前从未读过(见 | |
| 293 | + // identity-context.js 头注)。只对非 SKILL.md 模式补,因为 isSkillBased 的 agent | |
| 294 | + // 是「借住在别人本体目录下的一个 scoped skill」,不是一个完整身份,硬套 soul/identity | |
| 295 | + // 语义不通——它连 companyDir 都恒为 null(agent-manager.js 的 scoped-skill 分支)。 | |
| 296 | + parts.push(...buildIdentityContextParts({ | |
| 297 | + bodyDir: agent.bodyDir, | |
| 298 | + agentId: agent.agentId, | |
| 299 | + searchText: userTask, | |
| 300 | + })); | |
| 290 | 301 | } |
| 291 | 302 | |
| 292 | 303 | // 公司态 + 运行时坐标:**两条分支都要装**。SKILL.md 模式跳过的是 protocol 和 | ... | ... |
server/tests/unit/describe-role-definition-truncation.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Test — describeRoleDefinition 截断可见性(本体装配静默坑修复) | |
| 3 | + * | |
| 4 | + * 原实现把 agent.md 悄悄切到前 2000 字:职能说明如果排在文件后半(本体写得长一点 | |
| 5 | + * 就很常见),workgraph-planning 拿到的角色定义里根本没有这句话,而它不会知道 | |
| 6 | + * 自己看到的是被切过的半截——「硬约束不前置就等于没写」。 | |
| 7 | + * | |
| 8 | + * 这里锁的是:截断了就必须带⚠️提示 + 完整路径;没截断(原文本来就短)就什么都不多说。 | |
| 9 | + * 用真实 system/agents/ 目录起一个 zz- 前缀的临时本体(同款手法见 | |
| 10 | + * base-graduation-gate.test.js 的 zz-hire-test-co),跑完删掉,不留痕迹—— | |
| 11 | + * locator.bodyDir() 没有 env 覆盖点,读的就是真实仓库路径,测试只能顺着这条路走。 | |
| 12 | + */ | |
| 13 | +import { describe, it, expect, afterEach } from 'vitest'; | |
| 14 | +import fs from 'fs'; | |
| 15 | +import path from 'path'; | |
| 16 | + | |
| 17 | +const REPO_ROOT = path.resolve(__dirname, '../../..'); | |
| 18 | +const AGENTS_ROOT = path.join(REPO_ROOT, 'system', 'agents'); | |
| 19 | + | |
| 20 | +describe('describeRoleDefinition 截断可见性', () => { | |
| 21 | + const testAgentId = 'zz-test-role-trunc'; | |
| 22 | + const testAgentDir = path.join(AGENTS_ROOT, testAgentId); | |
| 23 | + | |
| 24 | + afterEach(() => { | |
| 25 | + fs.rmSync(testAgentDir, { recursive: true, force: true }); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it('agent.md 超过 2000 字时,截断段落带⚠️提示 + 完整路径', () => { | |
| 29 | + fs.mkdirSync(testAgentDir, { recursive: true }); | |
| 30 | + const longContent = `# ${testAgentId}\n` + 'x'.repeat(2500); | |
| 31 | + fs.writeFileSync(path.join(testAgentDir, 'agent.md'), longContent); | |
| 32 | + | |
| 33 | + delete require.cache[require.resolve('../../services/graph-engine.js')]; | |
| 34 | + const engine = require('../../services/graph-engine.js'); | |
| 35 | + const result = engine.describeRoleDefinition({ systemAgentId: testAgentId, companyId: null }); | |
| 36 | + | |
| 37 | + expect(result).toContain(`⚠️ 原文共 ${longContent.length} 字,已截断到前 2000 字`); | |
| 38 | + expect(result).toContain(path.join(testAgentDir, 'agent.md')); | |
| 39 | + // 截断本身没变——2000 字上限仍是故意的,摘录长度不应该被这次修复顺带改掉 | |
| 40 | + const excerpt = result.split(':\n')[1].split('\n(⚠️')[0]; | |
| 41 | + expect(excerpt.length).toBe(2000); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it('agent.md 不超过 2000 字时,不带任何截断提示', () => { | |
| 45 | + fs.mkdirSync(testAgentDir, { recursive: true }); | |
| 46 | + const shortContent = `# ${testAgentId}\n短本体`; | |
| 47 | + fs.writeFileSync(path.join(testAgentDir, 'agent.md'), shortContent); | |
| 48 | + | |
| 49 | + delete require.cache[require.resolve('../../services/graph-engine.js')]; | |
| 50 | + const engine = require('../../services/graph-engine.js'); | |
| 51 | + const result = engine.describeRoleDefinition({ systemAgentId: testAgentId, companyId: null }); | |
| 52 | + | |
| 53 | + expect(result).not.toContain('⚠️'); | |
| 54 | + expect(result).toContain('短本体'); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it('本体不存在时回落到明确的「未找到」提示,不是截断逻辑的边缘情况', () => { | |
| 58 | + delete require.cache[require.resolve('../../services/graph-engine.js')]; | |
| 59 | + const engine = require('../../services/graph-engine.js'); | |
| 60 | + const result = engine.describeRoleDefinition({ systemAgentId: 'zz-does-not-exist-role-trunc', companyId: null }); | |
| 61 | + expect(result).toContain('未找到该本体的 agent.md'); | |
| 62 | + }); | |
| 63 | +}); | ... | ... |
server/tests/unit/identity-context.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — identity-context.js(本体装配静默坑修复) | |
| 3 | + * | |
| 4 | + * 锁的是「work 节点只有 agent.md,soul/identity/tools/heartbeat/OKR/hippocampus/skills | |
| 5 | + * 全部缺失」这条修复的具体行为:读了哪些文件、按什么条件跳过、不重复已经被 | |
| 6 | + * PromptBuilder.buildCompanyStateParts 读过的那三样(user.md/memory.md/近期记忆)。 | |
| 7 | + * | |
| 8 | + * OkrService / retrieveRelevantPlans 两个依赖没有走 vi.mock——本仓库服务层是纯 | |
| 9 | + * CommonJS(require/module.exports),vi.mock 面向的是 Vite 处理过的 ESM 模块图, | |
| 10 | + * 对 identity-context.js 内部自己 require() 出来的这条链路不生效(实测:mock 完全不拦截, | |
| 11 | + * `okrMod.OkrService` 收到的还是真实的类,不是 vi.fn())。改用更直接的手法——CJS 的 | |
| 12 | + * `require()` 在同一进程内对同一路径返回**同一个** `module.exports` 对象,所以直接在 | |
| 13 | + * `beforeEach` 里把这两个依赖模块的导出属性**原地换成假实现**,`identity-context.js` | |
| 14 | + * 用 `require()` 拿到的就是换过的那份;`afterEach` 换回来,不留痕迹。 | |
| 15 | + * 这两个依赖也没有任何 env 覆盖点能让它们落到隔离的临时目录(不像 GRAPH_DB_PATH 那套), | |
| 16 | + * 真去读会既不确定又污染仓库数据,所以只验证「调用了、传参对不对、返回值有没有被 | |
| 17 | + * 正确拼进去」,不验证它们内部的真实检索逻辑(那是各自模块自己的测试该管的事)。 | |
| 18 | + */ | |
| 19 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | |
| 20 | +import fs from 'fs'; | |
| 21 | +import os from 'os'; | |
| 22 | +import path from 'path'; | |
| 23 | + | |
| 24 | +const okrModule = require('../../services/okr-service'); | |
| 25 | +const memoryModule = require('../../services/memory-service'); | |
| 26 | +const originalOkrService = okrModule.OkrService; | |
| 27 | +const originalRetrieveRelevantPlans = memoryModule.retrieveRelevantPlans; | |
| 28 | + | |
| 29 | +describe('identity-context.buildIdentityContextParts', () => { | |
| 30 | + let bodyDir; | |
| 31 | + let buildIdentityContextParts; | |
| 32 | + let okrCalls; | |
| 33 | + let hippocampusCalls; | |
| 34 | + | |
| 35 | + beforeEach(() => { | |
| 36 | + bodyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-ctx-body-')); | |
| 37 | + | |
| 38 | + okrCalls = []; | |
| 39 | + hippocampusCalls = []; | |
| 40 | + // 原地换成假实现:identity-context.js 的 require('../../services/okr-service') | |
| 41 | + // 拿到的是同一个 module.exports 对象,属性换了它就用的是假的。 | |
| 42 | + okrModule.OkrService = function FakeOkrService() { | |
| 43 | + return { | |
| 44 | + getActiveContextForAgent(agentId) { | |
| 45 | + okrCalls.push(agentId); | |
| 46 | + return agentId === 'has-goal' ? '## 🎯 当前参与的目标\n\n模拟目标文本' : ''; | |
| 47 | + }, | |
| 48 | + }; | |
| 49 | + }; | |
| 50 | + memoryModule.retrieveRelevantPlans = (agentId, searchText) => { | |
| 51 | + hippocampusCalls.push([agentId, searchText]); | |
| 52 | + return agentId === 'has-goal' ? `模拟检索结果(搜索词:${searchText})` : ''; | |
| 53 | + }; | |
| 54 | + | |
| 55 | + delete require.cache[require.resolve('../../src/core/identity-context.js')]; | |
| 56 | + ({ buildIdentityContextParts } = require('../../src/core/identity-context.js')); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + afterEach(() => { | |
| 60 | + okrModule.OkrService = originalOkrService; | |
| 61 | + memoryModule.retrieveRelevantPlans = originalRetrieveRelevantPlans; | |
| 62 | + delete require.cache[require.resolve('../../src/core/identity-context.js')]; | |
| 63 | + fs.rmSync(bodyDir, { recursive: true, force: true }); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + function write(rel, content) { | |
| 67 | + const fp = path.join(bodyDir, rel); | |
| 68 | + fs.mkdirSync(path.dirname(fp), { recursive: true }); | |
| 69 | + fs.writeFileSync(fp, content); | |
| 70 | + } | |
| 71 | + | |
| 72 | + it('bodyDir 为空时返回空数组,不抛错', () => { | |
| 73 | + expect(buildIdentityContextParts({ bodyDir: null })).toEqual([]); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it('soul.md/identity.md/tools.md 都写了时按顺序读出,缺的那个跳过', () => { | |
| 77 | + write('soul.md', 'MY SOUL'); | |
| 78 | + write('tools.md', 'MY TOOLS'); // identity.md 故意不写 | |
| 79 | + const parts = buildIdentityContextParts({ bodyDir }); | |
| 80 | + expect(parts).toContain('MY SOUL'); | |
| 81 | + expect(parts).toContain('MY TOOLS'); | |
| 82 | + expect(parts.join('\n')).not.toContain('identity.md'); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it('heartbeat.md 存在时读入,不存在时跳过(不报错)', () => { | |
| 86 | + expect(buildIdentityContextParts({ bodyDir })).not.toContain(expect.stringContaining('HEARTBEAT')); | |
| 87 | + write('heartbeat.md', 'HEARTBEAT CONTENT'); | |
| 88 | + expect(buildIdentityContextParts({ bodyDir })).toContain('HEARTBEAT CONTENT'); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it('没有 agentId 时不查 OKR / hippocampus(不是「查了但是空」,是根本不查)', () => { | |
| 92 | + buildIdentityContextParts({ bodyDir }); | |
| 93 | + expect(okrCalls).toEqual([]); | |
| 94 | + expect(hippocampusCalls).toEqual([]); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + it('有 agentId 时查 OKR 与 hippocampus,结果非空才拼进去', () => { | |
| 98 | + const parts = buildIdentityContextParts({ bodyDir, agentId: 'has-goal', searchText: '写一份 PRD' }); | |
| 99 | + expect(parts.some((p) => p.includes('模拟目标文本'))).toBe(true); | |
| 100 | + expect(parts.some((p) => p.includes('模拟检索结果'))).toBe(true); | |
| 101 | + expect(hippocampusCalls).toContainEqual(['has-goal', '写一份 PRD']); | |
| 102 | + | |
| 103 | + // agentId 存在但两个依赖都返回空字符串——不拼空段落 | |
| 104 | + const empty = buildIdentityContextParts({ bodyDir, agentId: 'no-goal', searchText: 'x' }); | |
| 105 | + expect(empty.some((p) => p.includes('目标') || p.includes('检索'))).toBe(false); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it('OKR 查询抛错时不影响其它内容,只是那一段缺失(不整体失败)', () => { | |
| 109 | + okrModule.OkrService = function ThrowingOkrService() { | |
| 110 | + return { getActiveContextForAgent: () => { throw new Error('boom'); } }; | |
| 111 | + }; | |
| 112 | + delete require.cache[require.resolve('../../src/core/identity-context.js')]; | |
| 113 | + ({ buildIdentityContextParts } = require('../../src/core/identity-context.js')); | |
| 114 | + | |
| 115 | + write('soul.md', 'STILL HERE'); | |
| 116 | + const parts = buildIdentityContextParts({ bodyDir, agentId: 'whoever' }); | |
| 117 | + expect(parts).toContain('STILL HERE'); | |
| 118 | + }); | |
| 119 | + | |
| 120 | + it('main_skill/SKILL.md 存在时带专属标题拼入', () => { | |
| 121 | + write('.claude/skills/main_skill/SKILL.md', 'MAIN SKILL BODY'); | |
| 122 | + const parts = buildIdentityContextParts({ bodyDir }); | |
| 123 | + expect(parts.some((p) => p.includes('我的核心主技能') && p.includes('MAIN SKILL BODY'))).toBe(true); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + it('workspace/_lessons.md 存在时带警示标题拼入', () => { | |
| 127 | + write('workspace/_lessons.md', '别再犯这个错'); | |
| 128 | + const parts = buildIdentityContextParts({ bodyDir }); | |
| 129 | + expect(parts.some((p) => p.includes('本地试错防坑经验') && p.includes('别再犯这个错'))).toBe(true); | |
| 130 | + }); | |
| 131 | + | |
| 132 | + it('skills.md 与其它 skill 子目录(排除 main_skill)都被扫到', () => { | |
| 133 | + write('.claude/skills/skills.md', 'SKILLS INDEX'); | |
| 134 | + write('.claude/skills/main_skill/SKILL.md', 'MAIN'); // 不应该被当成「依赖技能」再扫一次 | |
| 135 | + write('.claude/skills/foo-skill/SKILL.md', 'FOO SKILL BODY'); | |
| 136 | + write('.claude/skills/bar-skill/SKILL.md', 'BAR SKILL BODY'); | |
| 137 | + const parts = buildIdentityContextParts({ bodyDir }); | |
| 138 | + expect(parts).toContain('SKILLS INDEX'); | |
| 139 | + expect(parts.some((p) => p.includes('依赖技能: foo-skill') && p.includes('FOO SKILL BODY'))).toBe(true); | |
| 140 | + expect(parts.some((p) => p.includes('依赖技能: bar-skill') && p.includes('BAR SKILL BODY'))).toBe(true); | |
| 141 | + // main_skill 不能又被当成一个「依赖技能」重复拼一次 | |
| 142 | + expect(parts.filter((p) => p.includes('MAIN')).length).toBe(1); | |
| 143 | + }); | |
| 144 | + | |
| 145 | + it('.claude/skills 目录本身不存在时不报错,其它内容照常返回', () => { | |
| 146 | + write('soul.md', 'OK'); | |
| 147 | + expect(() => buildIdentityContextParts({ bodyDir })).not.toThrow(); | |
| 148 | + expect(buildIdentityContextParts({ bodyDir })).toContain('OK'); | |
| 149 | + }); | |
| 150 | +}); | ... | ... |