diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index ad2a525e..a9717bc5 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -266,11 +266,14 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) { PreferredProvider: firstNonEmpty(opts.PreferredProvider, a.preferredProvider), IO: childIO, StageHost: a.stageHost, - LightMemory: light, // 轻量内核:只有图记忆共同面 - AllowedOutputs: opts.AllowedOutputs, - KernelSource: parentID, // 子的 L4 只属于父 - ParentID: parentID, - TaskPrompt: opts.TaskPrompt, + // ★ 技能索引必须继承父的:不继承时子的 system prompt 里没有索引, + // 模型看不到"按需插件要先 plugin_start"这类说明(同 rtchild)。 + SkillIndexProvider: a.skillIndex, + LightMemory: light, // 轻量内核:只有图记忆共同面 + AllowedOutputs: opts.AllowedOutputs, + KernelSource: parentID, // 子的 L4 只属于父 + ParentID: parentID, + TaskPrompt: opts.TaskPrompt, // ★ 安全运行时必须继承(2026-10-30 补)。 // // 驻留子是长期挂着、无人盯的执行体:它自己跑调度器、接自己的输入、 diff --git a/internal/agent/core/rtchild.go b/internal/agent/core/rtchild.go index d8d29730..d069ac71 100644 --- a/internal/agent/core/rtchild.go +++ b/internal/agent/core/rtchild.go @@ -627,9 +627,17 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) { PreferredProvider: firstNonEmpty(opts.PreferredProvider, a.preferredProvider), IO: childIO, StageHost: a.stageHost, - AllowedOutputs: []string{opts.Output}, - KernelSource: string(a.id), - ParentID: string(a.id), + // ★ 技能索引必须**继承父的**(2026-11-04 修)。 + // + // 不继承的后果与 Security 那次同型:子 agent 的 system prompt 里 + // **完全没有 skill 索引**,于是它不知道该先读哪个 skill 就直接调工具。 + // 实测症状:A2A 分诊助手手上没有 cmd_run(cmd 插件 auto_start=false), + // 又看不到 cmd-tools skill 里"先 plugin_start cmd"的说明, + // 就把 terminal_create 当成命令执行器用,一条命令开一个终端。 + SkillIndexProvider: a.skillIndex, + AllowedOutputs: []string{opts.Output}, + KernelSource: string(a.id), + ParentID: string(a.id), // ★ 安全运行时**必须继承父的**(2026-10-30 补)。 // // rtchild 是高频、低延迟的执行体,且它照样能调工具(R3:rt 流不禁工具)。 diff --git a/internal/agent/core/skillindex_inherit_test.go b/internal/agent/core/skillindex_inherit_test.go new file mode 100644 index 00000000..08a350bc --- /dev/null +++ b/internal/agent/core/skillindex_inherit_test.go @@ -0,0 +1,147 @@ +package core + +// 判据:**子 agent 必须继承技能索引**(2026-11-04 生产缺陷)。 +// +// ## 缺陷原貌 +// +// `SetSkillIndexProvider` 只在 `cmd/homed/bootstrap.go` 里对**根 agent** 调了一次: +// +// if sp := pluginReg.Get("skillmgr"); sp != nil { +// if prov, ok := sp.(agentCore.SkillIndexProvider); ok { +// agent.SetSkillIndexProvider(prov) +// } +// } +// +// 而 rtchild / 驻留子 / 转投驻留子 各自 `New(AgentConfig{...})` 构造载体, +// 都**没有**传 `SkillIndexProvider`。于是子的 system prompt 里 +// **完全没有 skill 索引**。 +// +// ## 生产症状(真实日志) +// +// A2A 会话由「临时分诊助手」(驻留子)接手,它手上没有 `cmd_run` +// (`cmd` 插件 `auto_start=false`),而 `cmd-tools` skill 正文里写着 +// 「先 plugin_start cmd」。它看不到这条 skill,于是把 `terminal_create` +// 当成命令执行器:每条 `cd … && echo … && grep …` 开一个新终端, +// 终端随命令结束而退出,`terminal_read` 立刻撞 +// `read /dev/ptmx: input/output error`。 +// +// ## 与 Security 那次同型 +// +// 父有的能力,子没继承。`Security` 那次也是"只修了主 agent", +// 后来补成"构造期落位 + 各子构造点显式传"。 +// +// ## 判据为什么测 prompt 而不是测字段 +// +// 断言 `carrier.skillIndex != nil` 是结构判据:字段接上了,但 +// `buildSystemPrompt` 里注入那段被删了照样绿。所以直接看**生成的提示词**。 + +import ( + "strings" + "testing" + + agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" +) + +// fakeSkillIndex 是最小 SkillIndexProvider 替身。 +type fakeSkillIndex struct{ content string } + +func (f *fakeSkillIndex) SkillIndex() string { return f.content } + +// rtHostWithSkills 造一个带技能索引的父 agent + 成对 rt 通道。 +func rtHostWithSkills(t *testing.T, idx SkillIndexProvider) *Agent { + t.Helper() + pio := agentIO.NewIOManager() + host := New(AgentConfig{ + ID: "si-host", IO: pio, Provider: &rtBlockingProvider{}, + SkillIndexProvider: idx, + }) + if err := pio.RegisterDevice(&rtRecordingDevice{name: "siout"}); err != nil { + t.Fatalf("注册输出设备: %v", err) + } + for _, n := range []string{"siout", "siin"} { + if err := pio.ChannelRegistry().Register(agentIO.InputChannel{ + Name: n, Plugin: "sitest", + Capabilities: agentIO.CapText | agentIO.CapRT, + }); err != nil { + t.Fatalf("注册通道 %s: %v", n, err) + } + } + return host +} + +// TestRTChild_InheritsSkillIndex 钉住 rtchild 继承技能索引。 +// +// 变红条件:从 SpawnRTChild 的 AgentConfig 删掉 SkillIndexProvider ⇒ 红。 +func TestRTChild_InheritsSkillIndex(t *testing.T) { + const marker = "kernel-terminal v1.0.0 - Terminal waits for input" + host := rtHostWithSkills(t, &fakeSkillIndex{content: marker}) + defer host.StopRTChildren() + + id, err := host.SpawnRTChild(RTChildOptions{ID: "si-rt", Input: "siin", Output: "siout"}) + if err != nil { + t.Fatalf("SpawnRTChild: %v", err) + } + carrier := host.rtChildOf(id).agent + + prompt := carrier.buildSystemPrompt("", "hi") + if !strings.Contains(prompt, "kernel-terminal") { + t.Fatalf("★ rtchild 的 system prompt 里没有技能索引 —— "+ + "它看不到任何 skill,只能凭工具名猜用法(%d 字符的 prompt)", len(prompt)) + } + if !strings.Contains(prompt, "/dev/ptmx") && !strings.Contains(prompt, "Terminal waits for input") { + t.Fatalf("★ 索引内容没进 prompt(只匹配到了 skill 名?)") + } +} + +// TestResident_InheritsSkillIndex 钉住驻留子继承技能索引。 +// +// 这是**生产实际中招的那一类**:A2A 的临时分诊助手就是驻留子。 +// +// 变红条件:从 SpawnResident 的 AgentConfig 删掉 SkillIndexProvider ⇒ 红。 +func TestResident_InheritsSkillIndex(t *testing.T) { + const marker = "cmd-tools v1.0.0 - on-demand: plugin_start cmd first" + parent, _, dir := newRootWith(t, &countingProvider{}) + parent.skillIndex = &fakeSkillIndex{content: marker} + + spawnTestResident(t, parent, dir, "si-res") + rc := parent.residentOf("si-res") + if rc == nil { + t.Fatal("驻留子没创建成功") + } + prompt := rc.agent.buildSystemPrompt("", "hi") + if !strings.Contains(prompt, "cmd-tools") { + t.Fatalf("★ 驻留子的 system prompt 里没有技能索引 —— "+ + "这正是生产上'拿 terminal_create 当 cmd_run 用'的根因"+ + "(%d 字符的 prompt)", len(prompt)) + } +} + +// TestRootAgent_SkillIndexStillInjected 钉住根 agent 不受本次改动影响。 +// +// 保守侧:确认修复没有把根 agent 的路径改坏。 +func TestRootAgent_SkillIndexStillInjected(t *testing.T) { + const marker = "kernel-memory v1.0.0 - read before memory tools" + a := New(AgentConfig{ID: "root-si", SkillIndexProvider: &fakeSkillIndex{content: marker}}) + prompt := a.buildSystemPrompt("", "hi") + if !strings.Contains(prompt, "kernel-memory") { + t.Fatal("根 agent 的技能索引丢了 —— 本次改动改坏了原有路径") + } +} + +// TestChildWithoutSkillIndexIsNotBroken 钉住"父没有索引时子也不炸"。 +// +// 父可能没接 skillmgr(单测、独立工具)。那种情况下子照常工作, +// 只是 prompt 里没有索引段 —— 不能因为 nil 就 panic。 +func TestChildWithoutSkillIndexIsNotBroken(t *testing.T) { + host := rtHostWithSkills(t, nil) // 显式不接 + defer host.StopRTChildren() + + id, err := host.SpawnRTChild(RTChildOptions{ID: "si-none", Input: "siin", Output: "siout"}) + if err != nil { + t.Fatalf("父无技能索引时 SpawnRTChild 不该失败: %v", err) + } + prompt := host.rtChildOf(id).agent.buildSystemPrompt("", "hi") + if strings.Contains(prompt, "[Skills]") { + t.Fatal("父没有技能索引,子的 prompt 却出现了 [Skills] 段") + } +} diff --git a/internal/plugins/skillmgr/builtin.go b/internal/plugins/skillmgr/builtin.go index 6c1c8d73..14791ef7 100644 --- a/internal/plugins/skillmgr/builtin.go +++ b/internal/plugins/skillmgr/builtin.go @@ -28,11 +28,14 @@ package skillmgr // 用户改过的同名 skill 一律保留:内核不该覆盖人的修改。 import ( + "crypto/sha256" "embed" + "encoding/hex" "io/fs" "log" "os" "path/filepath" + "strings" ) //go:embed builtin @@ -44,9 +47,32 @@ const builtinSkillDir = "builtin" // skillFileName 与 plugin.LoadSKILL 认的文件名一致。 const skillFileName = "SKILL.md" +// builtinStampName 记录"上次由内核写入的版本"的摘要。 +// +// ★ 为什么需要它(2026-11-04 实测缺陷): +// +// 原规则是"目标目录已存在就跳过",本意是保住用户改动。但它把两件事混成一件: +// +// 用户改过的版本 ⇒ 应该保留 +// 内核自己写过的旧版 ⇒ 应该随内核更新 +// +// 两者都表现为"目录已存在",于是 **内核改名/改文档后,内置 skill 永远 +// 更新不到已部署机器**。实测:b850fea 把 `memory_recall` 改成 +// `stablemem_recall`,生产机的 kernel-memory 一直停在旧名, +// 模型照着旧名调工具必然失败。 +// +// 现在用摘要区分:把写入时的内容 sha256 记进 `.builtin-sha256`。 +// +// 当前内容 == 记录的 sha256 ⇒ 没人改过 ⇒ 可以安全覆盖成新版 +// 当前内容 != 记录的 sha256 ⇒ 用户改过 ⇒ 保留,只在日志里说明 +// 没有 stamp 文件(历史遗留)⇒ 保守处理,视为"用户版本",不覆盖 +// +// 保守默认很重要:老机器上没有 stamp,宁可不更新也不要覆盖掉人的修改。 +const builtinStampName = ".builtin-sha256" + // materializeBuiltinSkills 把内嵌 skill 落到 skillsDir,返回新写入的数量。 // -// 已存在的目录**不覆盖**:那是用户可能改过的版本。返回 0 表示无需写入。 +// 覆盖规则见 builtinStampName 的说明:只有"内核写的且未被改动"才覆盖。 func materializeBuiltinSkills(skillsDir string) int { if skillsDir == "" { return 0 @@ -63,14 +89,28 @@ func materializeBuiltinSkills(skillsDir string) int { } name := e.Name() dstDir := filepath.Join(skillsDir, name) - if _, statErr := os.Stat(dstDir); statErr == nil { - continue // 已存在(用户版本或上次落盘):不覆盖 - } data, readErr := fs.ReadFile(builtinSkills, builtinSkillDir+"/"+name+"/"+skillFileName) if readErr != nil { log.Printf("[skillmgr] embedded skill %s: %v", name, readErr) continue } + // 已存在:判断是不是"内核写的且没人改过",是才覆盖。 + if _, statErr := os.Stat(dstDir); statErr == nil { + if !builtinSkillUnmodified(dstDir) { + continue // 用户改过(或无法确认):保留人的版本 + } + if cur, err := os.ReadFile(filepath.Join(dstDir, skillFileName)); err == nil && string(cur) == string(data) { + continue // 内容已是最新,不用重写(保留 mtime) + } + if wErr := os.WriteFile(filepath.Join(dstDir, skillFileName), data, 0644); wErr != nil { + log.Printf("[skillmgr] update %s: %v", dstDir, wErr) + continue + } + writeBuiltinStamp(dstDir, data) + log.Printf("[skillmgr] updated builtin skill %s (kernel-owned, unmodified by user)", name) + written++ + continue + } if mkErr := os.MkdirAll(dstDir, 0755); mkErr != nil { log.Printf("[skillmgr] create %s: %v", dstDir, mkErr) continue @@ -79,6 +119,7 @@ func materializeBuiltinSkills(skillsDir string) int { log.Printf("[skillmgr] write %s: %v", dstDir, wErr) continue } + writeBuiltinStamp(dstDir, data) written++ } if written > 0 { @@ -86,3 +127,35 @@ func materializeBuiltinSkills(skillsDir string) int { } return written } + +// builtinSkillUnmodified 报告 skillsDir 下的 skill 是否"内核写的且未被改动"。 +// +// 判定依据是 .builtin-sha256 与**当前** SKILL.md 的实际摘要是否一致。 +// 没有 stamp(历史遗留或用户手动放置)⇒ 返回 false(保守:不覆盖)。 +func builtinSkillUnmodified(dir string) bool { + stamp, err := os.ReadFile(filepath.Join(dir, builtinStampName)) + if err != nil { + return false + } + cur, err := os.ReadFile(filepath.Join(dir, skillFileName)) + if err != nil { + return false + } + return strings.TrimSpace(string(stamp)) == sha256Hex(cur) +} + +// writeBuiltinStamp 记录本次写入内容的摘要,供下次判断"有没有人改过"。 +// +// 写失败只记日志、不影响 skill 本身可用:没有 stamp 的后果是 +// "下次不会被自动更新"(保守侧),而不是 skill 坏了。 +func writeBuiltinStamp(dir string, data []byte) { + if err := os.WriteFile(filepath.Join(dir, builtinStampName), []byte(sha256Hex(data)), 0644); err != nil { + log.Printf("[skillmgr] write stamp %s: %v", dir, err) + } +} + +// sha256Hex 返回 b 的十六进制 sha256。 +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/plugins/skillmgr/builtin/kernel-memory/SKILL.md b/internal/plugins/skillmgr/builtin/kernel-memory/SKILL.md index ad0dc927..fd674b3c 100644 --- a/internal/plugins/skillmgr/builtin/kernel-memory/SKILL.md +++ b/internal/plugins/skillmgr/builtin/kernel-memory/SKILL.md @@ -16,7 +16,7 @@ Four stores, one rule each: ## Retrieving | Need | Tool | -|---|---| +| --- | --- | | A past episode in full | `coldcontext_query` | | A fact or relation | `stablemem_recall` | | Written reference material | `knowledge_search` | @@ -33,5 +33,5 @@ again later, it lives on in the timeline until pruning moves it back. ## Writing - Facts worth keeping: `stablemem_commit` (triples). -- Written knowledge: `knowledge_write`. +- Written knowledge: `knowledge_create`. - Do not commit transient chatter, tool output, or your own status lines. diff --git a/internal/plugins/skillmgr/builtin/kernel-terminal/SKILL.md b/internal/plugins/skillmgr/builtin/kernel-terminal/SKILL.md new file mode 100644 index 00000000..804a6e50 --- /dev/null +++ b/internal/plugins/skillmgr/builtin/kernel-terminal/SKILL.md @@ -0,0 +1,65 @@ +--- +name: kernel-terminal +description: Terminal waits for input; it is NOT cmd_run. Read before using vim/ssh/top. +version: 1.0.0 +--- + +# Terminals vs one-shot commands + +Two different tools. Picking the wrong one looks like it works, then fails in a way that +points somewhere else. + +| Tool | Use for | Lifetime | +| --- | --- | --- | +| `cmd_run` | **One** command, then done: queries, scripts, `cd && … && grep …`, pipes, redirects, heredocs | Process exits when the command exits | +| `terminal_create` | A program that **keeps running and keeps asking for input**: `vim`, `ssh`, `top`, `less`, an interactive REPL | Stays open until you `terminal_close` or its `timeout` expires | + +## The failure this prevents + +Running `terminal_create` with a compound one-shot command +(`cd /x && echo … && ls … && grep …`) creates a terminal that exits the moment the command +finishes. Reading it then fails with `read /dev/ptmx: input/output error`. The next instinct is +to create another terminal for the next command — and now every "command" costs a terminal, +fills the terminal list, and each one dies mid-read. + +**If the command runs to completion on its own, it belongs in `cmd_run`.** A terminal is only +justified when the thing you are talking to is *still running and waiting for you*. + +## Driving a terminal + +`terminal_create` returns an ID. From then on the terminal is **silent by default** — it does +not push output at you. + +1. `terminal_read(id)` — read what is on screen now. Do this after acting, not on a timer. +2. `terminal_write(id, input=..., key=...)` — send text and/or a key (`Enter`, `Escape`, `C-c`) + in **one call**. `input` without `key` does not submit; the program just sits there. +3. `terminal_close(id)` — release it. Terminals default to a 5-minute `timeout`; leaving them + open wastes a slot. + +`notify` is opt-in. Without it you get nothing. Add it only when you genuinely want a wake-up +(`exit` for "tell me when it finished", `buffer=8192` for "tell me when there is a lot of +unread output"). Watching a fast-scrolling program with `interval` produces noise, not signal. + +## Sending keystrokes + +`key` is for **control**, `input` is for **text**: + +| Want | Send | +| --- | --- | +| Submit a command line | `input="ls -la"`, `key="Enter"` | +| Interrupt a running program | `key="C-c"` | +| Leave a pager or editor without saving | `key="Escape"`, then `key="q"` for pagers | +| Quit `vim` without saving | `key="Escape"`, then `input=":q!"`, `key="Enter"` | + +## Reading output that scrolled past + +`terminal_read` returns what is currently visible plus recent scrollback. If the output you need +scrolled out of range, do not blindly re-read in a loop — either grow `rows` at create time, or +for one-shot inspection prefer `cmd_run`, whose result is returned whole. + +## Checklist before reaching for a terminal + +- Does the command finish by itself? → `cmd_run`. +- Does it need me to type at it more than once? → `terminal_create`. +- Am I about to create a second terminal for "the next command"? → stop; the first one was + the wrong tool. diff --git a/internal/plugins/skillmgr/builtin_sync_test.go b/internal/plugins/skillmgr/builtin_sync_test.go new file mode 100644 index 00000000..77cd545c --- /dev/null +++ b/internal/plugins/skillmgr/builtin_sync_test.go @@ -0,0 +1,178 @@ +package skillmgr + +// 判据:内置 skill 的**更新语义**。 +// +// ## 为什么必须成组断言(2026-11-04 实测缺陷) +// +// 原规则是「目标目录已存在就跳过」,本意是保住用户改动。但它把两件事混成一件: +// +// 用户改过的版本 ⇒ 应该保留 +// 内核自己写过的旧版 ⇒ 应该随内核更新 +// +// 两者都表现为「目录已存在」,于是内核改名/改文档后,内置 skill +// **永远更新不到已部署机器**。 +// +// 实测后果:b850fea 把 `memory_recall` 改成 `stablemem_recall`, +// 生产机的 kernel-memory 一直停在旧名 —— 模型照着旧名调工具必然失败, +// 而日志里一个字都没有。 +// +// ## 所以这里必须四条一起断言 +// +// ① 首次安装:写入 + 写 stamp +// ② 未被改动 ⇒ 覆盖成新版(这是修掉的那个缺陷) +// ③ 用户改过 ⇒ **不**覆盖(保住原设计意图) +// ④ 没有 stamp ⇒ 不覆盖(保守:老机器上的目录可能是人的) +// +// 只断言 ② 会把 ③ 破坏掉(那就成了"内核覆盖用户修改")。 +// 只断言 ③ 则缺陷依旧(那就是原来的行为)。 + +import ( + "os" + "path/filepath" + "testing" +) + +// writeBuiltinFixture 在临时目录里造一个 skillsDir。 +func writeBuiltinFixture(t *testing.T) string { + t.Helper() + return t.TempDir() +} + +// putSkill 写一份 SKILL.md + 可选 stamp。 +func putSkill(t *testing.T, dir, content string, withStamp bool) { + t.Helper() + skillDir := filepath.Join(dir, "kernel-memory") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDir, skillFileName), []byte(content), 0644); err != nil { + t.Fatalf("write skill: %v", err) + } + if withStamp { + writeBuiltinStamp(skillDir, []byte(content)) + } +} + +// TestMaterializeBuiltinSkills_FirstInstall ①:首次安装写入并留 stamp。 +func TestMaterializeBuiltinSkills_FirstInstall(t *testing.T) { + dir := writeBuiltinFixture(t) + n := materializeBuiltinSkills(dir) + if n == 0 { + t.Fatal("首次安装应当写入内嵌 skill,实际写入 0 个") + } + stamp := filepath.Join(dir, "kernel-memory", builtinStampName) + if _, err := os.Stat(stamp); err != nil { + t.Fatalf("首次安装后没有 stamp 文件(%v)—— 下次就无法判断'有没有人改过'", err) + } + if !builtinSkillUnmodified(filepath.Join(dir, "kernel-memory")) { + t.Fatal("刚写入的 skill 却被判定为'已被改动'") + } +} + +// TestMaterializeBuiltinSkills_OverwritesUnmodified ②:**核心判据**。 +// +// 变红条件:把覆盖逻辑改回"目录已存在就 continue" ⇒ 红。 +func TestMaterializeBuiltinSkills_OverwritesUnmodified(t *testing.T) { + dir := writeBuiltinFixture(t) + // 先装一次(拿到真 stamp),再把内容改成"旧版"并同步 stamp, + // 模拟"内核上次写的是旧内容,但用户没动过"。 + if n := materializeBuiltinSkills(dir); n == 0 { + t.Fatal("前置:首次安装失败") + } + skillDir := filepath.Join(dir, "kernel-memory") + const stale = "# 旧版内容\n旧工具名 memory_recall\n" + if err := os.WriteFile(filepath.Join(skillDir, skillFileName), []byte(stale), 0644); err != nil { + t.Fatalf("写旧版: %v", err) + } + writeBuiltinStamp(skillDir, []byte(stale)) // 用户没动过 ⇒ stamp 与内容一致 + + n := materializeBuiltinSkills(dir) + if n == 0 { + t.Fatal("★ 内核自己写的旧版没有被更新 —— 内置 skill 的修复永远到不了已部署机器" + + "(这正是 memory_recall → stablemem_recall 那次改名失联的原因)") + } + got, err := os.ReadFile(filepath.Join(skillDir, skillFileName)) + if err != nil { + t.Fatalf("读回: %v", err) + } + if string(got) == stale { + t.Fatal("★ 返回了'已更新'但内容还是旧的") + } + // 更新后 stamp 必须跟着变,否则下次会被误判成"用户改过"。 + if !builtinSkillUnmodified(skillDir) { + t.Fatal("更新后 stamp 没同步 —— 下次启动会认为这是用户版本,从此不再更新") + } +} + +// TestMaterializeBuiltinSkills_KeepsUserEdits ③:用户改过的不覆盖。 +// +// 这是原设计意图,不能被 ② 的修复破坏。 +func TestMaterializeBuiltinSkills_KeepsUserEdits(t *testing.T) { + dir := writeBuiltinFixture(t) + if n := materializeBuiltinSkills(dir); n == 0 { + t.Fatal("前置:首次安装失败") + } + skillDir := filepath.Join(dir, "kernel-memory") + const userEdit = "# 我自己改的\n这一行是用户加的\n" + if err := os.WriteFile(filepath.Join(skillDir, skillFileName), []byte(userEdit), 0644); err != nil { + t.Fatalf("写用户版: %v", err) + } + // ★ 不动 stamp:模拟"用户手改了文件,但没更新 .builtin-sha256"。 + + materializeBuiltinSkills(dir) + got, err := os.ReadFile(filepath.Join(skillDir, skillFileName)) + if err != nil { + t.Fatalf("读回: %v", err) + } + if string(got) != userEdit { + t.Fatalf("★ 用户的修改被内核覆盖了(现在内容 %q)—— "+ + "内置 skill 的更新必须以'不覆盖人的改动'为前提", string(got)) + } +} + +// TestMaterializeBuiltinSkills_NoStampIsConservative ④:没有 stamp 视为用户版本。 +// +// 老机器上(功能上线前装的目录)没有 stamp。此时代码无从判断, +// 必须选保守侧:不覆盖。代价是那些机器需要手动重装一次。 +func TestMaterializeBuiltinSkills_NoStampIsConservative(t *testing.T) { + dir := writeBuiltinFixture(t) + const unknown = "# 来历不明的旧内容\n" + putSkill(t, dir, unknown, false) // 有内容、无 stamp + + // ★ 不能断言返回值为 0:内置 skill 有多个,其余几个目录不存在会被正常写入。 + // 第一版这么断言,判据红了但代码是对的 —— 这个坑记在这里: + // 计数是"本次写入总数",不是"是否覆盖了这一个"。 + materializeBuiltinSkills(dir) + + got, err := os.ReadFile(filepath.Join(dir, "kernel-memory", skillFileName)) + if err != nil { + t.Fatalf("读回: %v", err) + } + if string(got) != unknown { + t.Fatalf("没有 stamp 的 kernel-memory 被覆盖了(现在 %q)—— "+ + "无法确认那份是不是人改的,必须保守", string(got)) + } +} + +// TestMaterializeBuiltinSkills_Idempotent 内容最新时不重写(保住 mtime)。 +func TestMaterializeBuiltinSkills_Idempotent(t *testing.T) { + dir := writeBuiltinFixture(t) + if n := materializeBuiltinSkills(dir); n == 0 { + t.Fatal("前置:首次安装失败") + } + before, err := os.Stat(filepath.Join(dir, "kernel-memory", skillFileName)) + if err != nil { + t.Fatalf("stat: %v", err) + } + // 同 ④:返回值是"本次写入总数",此时全部已存在且最新 ⇒ 应为 0。 + if n := materializeBuiltinSkills(dir); n != 0 { + t.Fatalf("内容已最新时不该再写(写了 %d 个)—— 会无谓改动 mtime,干扰运维判断", n) + } + after, err := os.Stat(filepath.Join(dir, "kernel-memory", skillFileName)) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !after.ModTime().Equal(before.ModTime()) { + t.Fatal("内容未变却重写了文件(mtime 变了)") + } +}