12 Commits

Author SHA1 Message Date
b879558947 feat(proc): 共享段 arena 容量可配置(启动期生效,需重启内核)
## 改了什么

- `proc.NewHostWithArenaSize(bytes)`:按给定容量建段;保留 `NewHost()`
  以兼容默认 4MB。
- 容量低于分配器最小尺寸时**返回明确错误**,不静默回落默认值
  (否则"我配了 32MB"会悄悄变成"仍是 4MB")。
- 删除 `arenaPublishSize` 变量:自从容量改为入参后已无人读取,
  留一个"看着能改、改了没效果"的开关比没有更危险。
- `core.plugin.shm_arena_mb`(默认 4,clamp 1..256):播种 + 定义 +
  `Registry.arenaBytesFromConfig()` 读取。越界时记日志说明被 clamp。

## 为什么容量只能在启动时决定

arena 是统一区域里一段连续 mmap,而 `SharedRef` 存**绝对偏移**。
变大需要 remap,而每个 plugin.bin 子进程可能正在读同一页 ——
跨进程 munmap 会让它 SIGSEGV。该路径历史上三次被明确搁置。
⇒ 本改动只决定"这次启动开多大",配置项描述里已写明需重启。

## 判据(`internal/plugin/proc/test/arena_contract_test.go`)

    TestArenaHostHonoursRequestedCapacity
      给定 8MB → 能写入并读回 4MB(证明容量真的生效)
    TestArenaHostRejectsTooSmallCapacity
      64 字节 → 明确报错

变异验证:
    忽略传入容量、总用默认 4MB   → 4MB 写入报「超出 arena 容量 4194228」
    过小容量改为静默回落默认     → 判据红

    go build ./... / go vet ./...                  PASS
    python3 .github/scripts/check-ci-gates.py      PASS
    go test ./... -count=1 -race                   51 包 / 100 包无测试 / 0 FAIL
    gofmt -l                                       干净
2026-11-14 11:32:03 +08:00
d0e4f10b53 test(proc): arena 共享内存的模块职责判据(面向接口)
只用公开入口 proc.NewHost() 与 Host.Arena() 的导出方法;断言只看
"写入的字节能否原样读出 / 操作成功与否 / 空间能否被再次使用",
不碰块头布局、空闲链结构、内部字段。

    写入→读出→归还→同尺寸再写入
    超限请求失败且不破坏分配器
    非 owner 归还被拒、块仍可读
    旧 generation 读取报错(不返回错位数据)
    插件退出回收后空间可复用
    回收不波及内核自有块
    伪造 offset 被拒
    重复归还被拒
    碎片化导致大请求失败(钉住现状)

变异验证:
    Free 不标记为空闲      → 第二次 Put「空间不足」红
    去掉归属校验           → 非 owner 归还被接受
    去掉 generation 校验   → 过期引用被接受

## 实测发现的真实碎片故障

4MB 段内用 1MB 大块与小块交替铺满、释放三个大块后:

    used=64 total=4194308 blocks=8
    请求 2096090 字节 → arena: 空间不足(请求 2096090 字节,容量 4194228)

空闲总量远超请求,但被小块隔开 ⇒ first-fit 拿不到连续块。
Exchange Arena 只有邻块合并,没有整体整理(唯一的 Compact 属于
StageContext 段,不是这块内存)。

## 未完成(见 todo.md §7.2)

块搬运会改变 SharedRef.Offset,而持 ref 的是插件侧/调用栈局部变量,
内核无法批量改写 ⇒ 整理会使命中窗口内所有 ref 失效。需先决定形态
(仅内核自有块 / 零活跃窗口 / 改分配策略),再实现。

    go test ./internal/plugin/proc/test -count=1 -race   PASS
    go build ./... / go vet ./...                       PASS
    gofmt -l                                            干净
2026-11-14 11:20:42 +08:00
b73ff3786a test(security): 补 Gate 明文拦截/回填与 Store 跨重启持久化契约
Codec 原有 7 条保留。新增模块职责层判据:

    TestGateReplacesSecretOutboundAndBackfillsForExecution
      出向文本里出现真实凭据 → 被替换成 @{alias},明文不再出现
      执行前 FillForExecution 把别名换回真实凭据
    TestGateScrubsSecretInbound
      入向泄漏的凭据被清洗,订阅侧看不到明文
    TestGateDisabledPassesTextThrough
      关闭状态原样透传、拦截计数 0
    TestStoreAliasRoundTripSurvivesReopen
      Submit 拿别名 → 重新打开同一存储 → Fill 回填出原文

    变异验证:
      去掉回填分支                 → 别名不再换回真实凭据(先前的失败)
      出向不产出别名               → outbound secret was not intercepted

    过程中修正了两处我自己写错的期望(回填目标必须先取别名、
    引用格式是 @{alias}),不是放宽实现。
2026-11-14 10:38:37 +08:00
5700531169 test(events): 补授权拒绝与脱敏投递的模块契约
新增 TestPublishAsReturnsDeniedErrorAndBlocksDelivery
      策略拒绝 → 返回可诊断 DeniedError,订阅者零投递
    新增 TestSubscribersReceiveScrubbedPayload
      入向脱敏后订阅者看不到明文
    新增 TestHandlerPanicDoesNotBreakOtherSubscribers
      handler panic 不影响其他订阅者
    变异:绕过 AllowPublish 判断 → denied publish 返回 nil 错误
          跳过 ScrubInbound     → 明文 SECRET 到达订阅者
2026-11-14 10:30:50 +08:00
29bfad63d6 test(agent/core): StageHost 增加失败语义与工具摘除契约
新增 TestRunStageCollectsHandlerErrors
      handler 返回 error → 汇总进 ctx.Errors,其他 handler 的产物保留
    新增 TestRunStagePanicDoesNotKillRun
      handler panic 被隔离,其余 handler 照常执行
    新增 TestUnregisterPluginToolsOnlyThatPlugin
      工具摘除只影响目标插件,其他插件工具仍可执行

    变异验证:
      去掉 error 收集         → errors=[] 断言红
      去掉 recover 隔离       → panic 直接炸掉测试进程(rc=1)
2026-11-14 10:28:45 +08:00
b215bfb3ad test: io/lua/system/tracker 改为模块职责级黑盒契约
## internal/agent/io

    原:只有 OutputCapability 位标志 + String
    现:
      InjectText → 输入事件(source/output_channel/request_id)
      InjectInputTo → 显式输出通道被保留
      InjectTextSync → 响应回程交到调用方
      RegisterDevice → GetDevice → StartAll/StopAll 真实调用设备
      重复注册被拒;StartAll 传播设备启动失败
    能力标志语义保留(任一位命中)
    变异:允许重复注册 / 吞掉启动错误 → 均断言红

## internal/lua

    新增:
      transform_request 真的执行 Lua body(原来只验证加载与未加载报错)
      endpoint/headers 静态声明经公开 API 读回
    变异:transform_request 直接返回入参 → 断言红

## internal/system

    原:只断言 archived=true 与 baseline 目录非空
    现:留档 → 覆盖 → RestoreFileFromBaseline 内容还原为原文
         非受保护路径与新建文件不留档
         内容未变时重复留档不产生新档案
    变异:留档写空内容 / 去掉幂等判断 → 均断言红

## internal/tracker

    原:无文件变更时断言无 changeset,rollback 起点为空
    现:真实 upper 目录写入 → PostAction 记录 ChangeFileCreated
         修改原文 → RollbackLatest 恢复原文内容
         新建文件 → RollbackLatest 后消失
         空历史定向回滚报错
    变异:不记录变更 → 两条断言红

## 验证

    go build ./... / go vet ./...              PASS
    gofmt -l                                  干净
    CI 门禁自检                                PASS
    go test ./... -count=1 -race              51 测试包 / 100 包无测试 / 0 FAIL
    源码目录 _test.go                          0
2026-11-14 10:22:12 +08:00
a7e8f55085 test: 把 helper/getter 判据改写为模块职责级黑盒契约
四个模块从「导出符号调用」改为「模块职责 → 可观察结果」。

## internal/memory

    TestCommitThenRecallAfterReopen
      Commit → Close → 重开同一库 → Recall 核对主/谓/宾
      (原来只检查 GraphData 返回 map 里有 nodes 键)
    变异:Recall 恒空 → 断言红

## internal/memory/pipeline

    TestAppendThenDistillWritesQueryableFact
      Append → Start → 注入 splitter → 图库 Recall 命中
    TestRawRecordsPersistAcrossRestart
      Append → Stop → 新实例 Start → 回读原始记录
    TestSplitterFailureDoesNotFabricateFacts
      拆分失败必须回退,不得编出事实
    TestPauseResumeAndStopAreObservable
    变异:跳过蒸馏/跳过落盘 → 均断言红

## internal/nlp

    真实 Parser → 三元组 + 句子溯源 + 分值
    融合阈值改变取舍(高阈值空、低阈值还原)
    SetDefaultParser 影响 NewExtractor(nil)
    变异:丢弃 dep 抽取 / 取消阈值截断 → 均断言红

## internal/config

    新增 TestSecretSurvivesRestartAndStaysEncrypted
      敏感项 Set → 磁盘无明文 → Close → 重开 → GetSecret 还原明文
    变异:GetSecret 不解密 / Set 不加密落盘 → 均断言红
    删除了一条不成立的断言(普通 Get 本应返回明文),按真实契约收紧。

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    51 测试包 / 100 包无测试 / 0 FAIL
    4 个改动包 -race                          全绿
    源码目录 _test.go                         0
2026-11-14 10:04:44 +08:00
41926e770d fix(lua): 适配器永久落后 —— 修死代码 + 改为显式 opt-in 同步
## 缺陷:适配器会永久卡在旧版

writeBundledAdapters 用清单里的 prevHash 判断「盘上有没有被人动过」,
被动的那个就保留。但清单只存 hash ——「内置旧版本的 hash」与
「用户改过的 hash」在清单里长得一模一样。于是:

    记了盘上那份当新基线 ⇒ 下次 onDisk == prevHash
      ⇒ 被误判成「没跟上新版本」⇒ 覆盖掉用户改动
    而保留分支里的 cur[name] = onDisk 又会

## 被循环末尾无条件 `cur[name] = sum` 冲掉

⇒ 判「用户改过」后清单仍记内置 hash ⇒ 下次再判一遍「用户改过」
⇒ **永久**停在旧版。日志只有一句「保留用户版本」,
看起来像用户自己的选择,完全看不出是卡住。

本机实测 8 个适配器中招(openai/anthropic/github/groq/mistral/ollama/
kimicode/server),落后内置版 3000~6500 字节,缺的是用量归一化
(cache_read / reasoning_tokens)、流式 tool_calls 等真实修复。
症状最终表现为「模型不听话」「工具调不动」——指不到适配器身上。

## 为什么不能靠「自动覆盖」解决

「旧发行版残留」与「用户手工改过」在文件内容上无法区分(都只是
hash 不等于内置版)。擅自改用户盘上的文件,比落后一个版本更糟。

所以改成**显式 opt-in**:

    core.lua.adapter_sync = off(默认)  不碰文件,但告警必须可操作
    core.lua.adapter_sync = auto         差异即备份 .bak-<hash8> 后覆盖

零值 = off:忘记注入配置时宁可不动用户文件,也不能默默全量覆盖。

## 告警要可操作,不是「保留用户版本」一句话

教训(2026-10-26 + 2026-11-11 两次):这类静默落后最难查,因为症状
指向模型而不是适配器。现在会给出:数量、名单、影响、三条处理路径
(跟随内核 / 长期定制改 adapter_path / 删掉旧文件)。

长期定制的正道是 `core.llm.sources.<name>.adapter_path`(每源独立可配),
不需要改内置位置的同名文件 —— 这一点也写进告警里。

## 判据 7 条,均经变异验证

    TestAdapterSyncDefaultsToOff            零值/未注入必须是 off
      (变异:return "off" → "auto" ⇒ 红)
    TestOffModeLeavesDiskUntouched          off 不碰文件、不产生备份
      (变异:case mode=="auto" → case mode!="" ⇒ 红)
    TestAutoModeUpdatesWithBackup           auto 更新且备份可回滚
    TestAutoModeNoRedundantBackup           一致时不重复堆 .bak
    TestAdapterStateStableAcrossRestarts    两种模式启停 3 次不漂移
    TestManifestCoversAllBundledAdapters    清单不漏项
    TestFirstInstallHappensInBothModes      off 只「不动已存在文件」,≠不安装

    go build / vet / gofmt / go test -race  全绿

## 生产现状(已按用户确认保留更新)

    适配器 10/10 与内置一致,8 个 .bak-<hash8> 可回滚
    服务 active,NRestarts=0
2026-11-11 21:39:03 +08:00
f7dad243b6 fix(deepseek): reasoning_content 三层贯通 + thinking 关闭层级修正
## 生产故障

    all 2 providers failed, last error: api error 400:
    "The reasoning_content in the thinking mode must be passed back to the API."

## 两个根因,第一个更根本

### ① thinking 关闭一直没生效(真正的触发器)

deepseek.lua 写的是 `req.extra_body.thinking = {type="disabled"}`。
但官方文档里那个 `extra_body={"thinking":...}` 是 **OpenAI SDK 层参数** ——
SDK 会把它摊平进 HTTP 请求体顶层。而适配器是**裸发** JSON 给 api.deepseek.com,
多包一层 `extra_body` 服务端根本不认,直接忽略。

⇒ 即使内核 thinking_enabled=false(发出 disable_thinking=true),
   也从来没真正关掉过思考,每次都走 thinking 模式,每次都撞 reasoning_content 规则。

改为顶层 `req.thinking = {type="disabled"}`。

### ② reasoning 从未被保存和回传

DeepSeek 规则(官方 thinking_mode 指南):请求**带 tools** 时,
所有历史轮的 reasoning_content 都必须传回;内核每轮都带工具定义。

而内核三层全丢:

    L1  ContextEvent 没有该字段;renderL1Messages 渲染 assistant 时不带
    L2  coldcontext 只切 Input/Response 两块,推理内容不进块
    L3  蒸馏原文只读 Input/Response/工具输出

修法(按用户裁定:L1/L2/L3 都要包含 reasoning):

    L1  ContextEvent.ReasoningContent + renderL1Messages 回传
        顺带:textForVector 纳入 reasoning —— 关键线索常只在思考里
        ("用户要的是 A 不是 B,所以先查 C"),只看 Response 会误裁
    L2  RoleReasoning 独立块(不并进 Response:回灌时要放回不同字段,
        合并会让思维链被当成正式回复发给模型)
    L3  coldcontextOriginalText 按**时序** input → reasoning → response

## 一个易错点:不能用 f.Resp.ReasoningContent

f.Resp 只在「本批有工具调用」时赋值(stepLLM 里 len(ToolCalls)==0 就 return)。
纯文本收尾轮拿到的是**上一次工具轮**的旧响应 —— 用它写进 L1 会把上一批的
思维链冒名顶替成本轮的(比缺失更坏)。所以单独一个 TurnReasoning,
由 stepLLM 在分支**之前**无条件写入。

## 适配器兜底(治存量)

内核修了,但**存量会话**的 context.json 是修复前写的,那些事件没这个字段。
不兜底就是死结:历史缺字段 ⇒ 400 ⇒ 会话永久不可用(重启也救不回,
缺的字段在磁盘上的旧记录里)。

transform_request 里:带 tools 时给每条 assistant 补 reasoning_content="",
已存在则不覆盖(不抹掉真实思考内容)。

## 顺手修的既有缺陷

零时间戳时 docBlocksForEntry 用 time.Now() 算块 ID,而 ColdEvent.Timestamp
留零值 ⇒ 哈希永久失配 ⇒ 内容就在那里却一个字段都读不回,且不报错不记日志。
线上因为 L1 总带时间戳没暴露。docEventsFromEntries 里先归一化时间戳。

## 验证

    生产实机(A2A 多轮工具调用,日志确认落到 deepseek 源直连):
      LLM fallback: trying provider "deepseek"
      response (12186ms, tools=[cmd_run plugin_start cmd_run])
      reasoning_content 400 = 0 次

    单元判据 16 条,全部变异验证(改回错误形态即红):
      coldcontext/test/reasoning_contract_test.go   5 条(L2 往返/独立块/
        无思考不造空块/特殊字符/InOut 兼容)
      agent/api/test/reasoning_protocol_test.go     3 条(顶层字段/无思考不发/
        带 tool_calls 仍发)
      lua/test/deepseek_reasoning_test.go           8 条(补空串/不覆盖真实值/
        无 tools 不补/补全部轮/不破其他字段/顶层 thinking/默认不加 thinking)

    go build / vet / gofmt / go test -race  全绿
2026-11-11 21:38:54 +08:00
319fa91ce7 test(cfgmgr): add module-level configuration workflow contract (wip) 2026-11-10 09:06:44 +08:00
3d05dcac71 test: 按模块导出接口重写黑盒判据(批次 14:mcp / proc / cfgmgr)
三个模块新增 test/ 黑盒契约,测试仅通过导出 API 驱动。

## internal/plugins/mcp

    TestListToolsThroughHTTP
    TestCallToolSuccessAndEmptyContentHandling
    TestCallToolSurfacesIsErrorAsFailure
    TestCallToolSurfacesRPCError
    TestCallToolSurfacesHTTPError
    TestListToolsParseSSEStreamFrame

使用 httptest + NewSSETransport + NewServer,覆盖 JSON-RPC 工具列表、
成功调用、空内容失败、MCP isError、RPC 错误、HTTP 错误和 SSE data 帧。

## internal/plugin/proc

    TestMajorVersionParsing
    TestKnownCapabilitiesExcludesCoreAndIsStable
    TestSharedRefSliceBoundary
    TestPublicCapabilityConstantsStable

覆盖版本主段解析、能力清单排序/去重/core 排除、共享引用切片边界和能力常量。

## internal/plugins/cfgmgr

    TestStartRegistersConfigToolsWithConcurrencyMarkers
    TestStopIsSafe

通过公开 Start 注入 SDK 工具注册回调,观察七个配置工具及其
Serial/ParallelSafe 并发契约;未访问私有 handler。

## 变异验证

    mcp       忽略 MCP isError         → isError 未被判失败
    proc      MajorVersion 不截断       → 多组版本断言红
    cfgmgr    写工具取消 Serial 标记    → 写工具未声明 Serial

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    51 个测试包 / 100 包无测试 / 0 FAIL
    3 个新包 -race                            全绿
    源码目录 _test.go                         0
2026-11-09 21:37:53 +08:00
e74d66492d test: 按模块导出接口重写黑盒判据(批次 13:sdk / skillmgr / remotedevice)
三个模块新增 test/ 黑盒契约,测试仅使用模块导出 API。

## internal/sdk

    EffectiveProxyAuthNormalizesLegacyAndEmpty
    ProxyHostNormalizationAndValidation
    NewConfigGetPutRoundTrip
    BuiltinProxyRegistrySnapshotsAndClear

覆盖鉴权归一化、DNS host 标签校验、ConfigAPI 往返、内置反代声明表快照与清理。

## internal/plugins/skillmgr

    SkillDescriptionsListsScannedSkillsInStableOrder
    SkillDescriptionsEmptyBeforeStart

通过公开 New/Start/SkillDescriptions/Name 验证技能目录扫描、稳定排序与空目录行为。

## internal/plugins/remotedevice

    SaveResultThenGetResult
    SupportsToolForUnknownAndUnknownCapability
    SaveInlineMediaRequiresDirectoryAndPersistsBytes

覆盖结果存取、未知设备能力查询、媒体目录配置、base64 落盘、摘要和元数据。

## 变异验证

    sdk       注册版本不递增                 → registration did not advance public version
    skillmgr  不写入技能表                   → scanned descriptions missing
    remotedevice 丢弃 SaveResult             → GetResult=false

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    48 个测试包 / 100 包无测试 / 0 FAIL
    3 个新包 -race                            全绿
    源码目录 _test.go                         0
2026-11-09 21:13:49 +08:00
37 changed files with 3042 additions and 318 deletions

View File

@ -0,0 +1,129 @@
package test
// reasoning_content 的**协议契约**判据(2026-11-11)。
//
// ## 为什么要单独钉协议层
//
// 生产故障:
//
// all 2 providers failed, last error: api error 400:
// "The reasoning_content in the thinking mode must be passed back to the API."
//
// DeepSeek 官方规则(api-docs.deepseek.com/guides/thinking_mode):
//
// 请求带 tools 时,**所有历史轮**的 reasoning_content 都要传回,并拼进上下文。
//
// 而内核每轮都带工具定义,所以这条必然触发。修复横跨三层:
//
// L1 存(ContextEvent.ReasoningContent)+ 渲染回传
// L2 落块(RoleReasoning 独立块)
// L3 进蒸馏原文
//
// 本文件**只钉最外面那一层**:内核发给上游的 JSON 里,
// reasoning_content 是不是 assistant 消息的**顶层字段**。
//
// ## 为什么必须是顶层字段(而不是塞进 content)
//
// OpenAI 兼容格式(DeepSeek 走这条)规定:
//
// {"role":"assistant","content":"回复","reasoning_content":"思考"}
//
// 把思考拼进 content 会同时坏两件事:
// · 上游认为"没回传",照样 400
// · 模型下一轮会把自己的内心独白读成"我说过这话"
//
// 对照:Anthropic 才是 content block(thinking block)。我们走 OpenAI 兼容,
// 所以是顶层字段 —— 两者不能混。
import (
"encoding/json"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// 判据:reasoning_content 是 assistant 消息的顶层字段,与 content 平级。
//
// 变异验证(实测):把 Message.MarshalJSON 里写 reasoning_content 的那段删掉
// ⇒ 本判据红(字段消失,上游就会 400)。
func TestReasoningContentIsTopLevelField(t *testing.T) {
msg := api.Message{
Role: "assistant",
Content: "已查到:上月在 12 号出账",
ReasoningContent: "先确认问的是账单不是订单",
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
v, ok := got["reasoning_content"]
if !ok {
t.Fatalf("★ 消息里没有 reasoning_content 顶层字段(上游会 400):%s", raw)
}
if v != "先确认问的是账单不是订单" {
t.Fatalf("★ reasoning_content = %v", v)
}
// content 必须保持原样,不被思考污染。
if got["content"] != "已查到:上月在 12 号出账" {
t.Fatalf("★ content 被污染:%v", got["content"])
}
}
// 判据:无思考时**不发**该字段(不伪造空字段)。
//
// 为什么:三个层次都要区分「没有思考」与「思考是空的」。
// 无条件发空串会让每条消息都带一个无意义字段,也会让上游
// 无从判断"这一轮到底有没有思考过"。
func TestNoReasoningOmitsField(t *testing.T) {
msg := api.Message{Role: "assistant", Content: "普通回复"}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(raw), "reasoning_content") {
t.Fatalf("★ 无思考时不该出现该字段:%s", raw)
}
}
// 判据:带着 tool_calls 的 assistant 消息,reasoning_content 仍必须出现。
//
// 这条最贴近真实故障形态:DeepSeek 的触发条件是"**带 tools** 的请求历史",
// 而实际报错的具体消息正是 **assistant(tool_calls)** 那条 ——
// 模型先思考、再决定调工具,工具结果回来时那条 assistant 必须带着思考。
func TestReasoningWithToolCalls(t *testing.T) {
msg := api.Message{
Role: "assistant",
Content: "",
ReasoningContent: "需要查一下 invoice 表",
ToolCalls: []api.ToolCall{{
ID: "call_1",
Name: "sql_query",
Arguments: map[string]interface{}{"sql": "select * from invoice"},
}},
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["reasoning_content"] != "需要查一下 invoice 表" {
t.Fatalf("★ 带 tool_calls 的 assistant 丢了 reasoning_content:%s", raw)
}
// content 为空的 assistant(tool_calls) 应发 null(OpenAI 语义),
// 不能变成空串 —— 有些上游对两者处理不同。
if c, ok := got["content"]; !ok || c != nil {
t.Errorf("content 期望 null,实际 %v", c)
}
if _, ok := got["tool_calls"]; !ok {
t.Error("★ tool_calls 丢了")
}
}

View File

@ -67,13 +67,41 @@ type ContextEvent struct {
//
// 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
ID string `json:"id,omitempty"`
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
ID string `json:"id,omitempty"`
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
// ReasoningContent 是本轮 assistant 的思考内容(thinking 模式的思维链)。
//
// ## 为什么必须存并必须回传(2026-11-11 生产故障)
//
// 生产报:
//
// all 2 providers failed, last error: api error 400:
// "The reasoning_content in the thinking mode must be passed back to the API."
//
// DeepSeek 官方规则(api-docs.deepseek.com/guides/thinking_mode):
//
// 后续请求是否要回传 reasoning_content,取决于请求是否带 tools:
// 带 tools ⇒ **所有历史轮的 reasoning_content 都要传回**,并会拼进上下文
//
// 而内核**每一轮都带 tools**(工具定义常驻),所以这条必触发。
//
// 此前这里没有这个字段:resp.ReasoningContent 收到了(只用于发事件、
// 显示思考过程),但**没进 L1**,下一轮 renderL1Messages 自然带不上
// ⇒ 只要对话里出现过一次工具调用 + 思考,整个会话就永久 400
// (上游多家 agent 项目都踩了同一个坑:langchain #37179、n8n #34924、
// opencode #24722、codex #24500)。
//
// 语义边界:这是**协议字段**,不是记忆内容 ——
// · 不向量化、不进 L2/L3 蒸馏(思维链不是事实)
// · 只在渲染 provider 消息时回填
// · omitempty:存量 context.json 读回来为空串,不伪造
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// ToolCalls 是本次事件里**每一次**工具调用的完整记录(含参数与配对键)。
//
@ -833,6 +861,34 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD
text = evt.Input
}
// ★ 思维链参与 L1 相关性打分(2026-11-11 用户裁定)。
//
// 为何应当包含:L1 裁剪是拿**当前输入**给每条历史事件打分,决定哪些
// 留在窗口里。而关键线索常只在思考里:
//
// 用户其实要的是 A 不是 B ⇒ 所以先查 C ⇒ (回复里只说"已查到")。
//
// 只看 Response 会把这轮判成"与当前输入无关"而剔掉,下一轮就得重查。
// 思考内容正是“为什么这条历史相关”的真实依据。
//
// ★ 边界(为何这与“思维链不进长期记忆”不矛盾):
//
// L1 向量 = 本轮窗口内的取舍打分(临时坐标,Prune 后重算)
// L2/L3 = 长期记忆(coldcontext 只取 Input/Response,见 coldcontext 注释)
//
// 所以思维链可以参与前者,但**不**进后者。冷化时它随事件一起
// 离开 L1,不会被制成文本块、不被蒸馏成三元组。
//
// 放长在 Cleaner **之前**:与 Response 同路也过清理(同是模型产出,
// 没有理由一个洗一个不洗)。
if evt.ReasoningContent != "" {
if text == "" {
text = evt.ReasoningContent
} else {
text = text + "\n" + evt.ReasoningContent
}
}
// 计算层:应用输入通道的 Cleaner(不改原文,仅在计算层清洗)
if channelDefLookup != nil {
if chDef, ok := channelDefLookup(evt.Source); ok && chDef.Cleaner != nil {
@ -1192,13 +1248,16 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, col
entries := make([]coldcontext.ContextEntry, len(archive))
for i, s := range archive {
entries[i] = coldcontext.ContextEntry{
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
ToolResults: convertToolResults(s.event.ToolResults),
ToolCalls: toolCallItemsToDoc(s.event.ToolCalls),
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
// ★ 思考内容必须随事件进 L2(2026-11-11 用户裁定)。
// 漏了它,事件一旦被裁下,思维链就永久丢失。
ReasoningContent: s.event.ReasoningContent,
ToolResults: convertToolResults(s.event.ToolResults),
ToolCalls: toolCallItemsToDoc(s.event.ToolCalls),
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
}
}
doc, err := coldcontextStore.ContextToColdContext("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())

View File

@ -612,7 +612,12 @@ func isTemplateSummary(s string) bool {
return strings.HasPrefix(s, "来自 ") && strings.Contains(s, "条对话")
}
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
// reasoning 是本轮模型的思考内容(thinking 模式)。空串表示无思考。
//
// ★ 为何是参数而不是从 a 上读字段:本轮思考属于**这个 TaskFrame**
// (f.TurnReasoning),不是 agent 的全局状态。agent 级字段在并发任务下
// 会互相覆盖 —— 而记忆候选一旦写错就是持久化的错。
func (a *Agent) emitMemoryCandidate(source, input, response, reasoning string, toolResults []ToolResultItem, toolsUsed []string) {
payload := map[string]interface{}{
"source": source,
"input": input,
@ -622,6 +627,15 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults
"agent_id": string(a.id),
"timestamp": time.Now().Unix(),
}
// ★ 思考内容也要进记忆候选(2026-11-11 用户裁定:L2/L3 都包含 reasoning)。
//
// memory_candidate 是**不经 context** 的另一条持久化侧路,所以它不会
// 从 ContextEvent 那里自动带上 reasoning —— 必须显式放进去。
// 漏了它,走这条路的消费方拿到的记忆就只有"做了什么",没有"为何"。
// 空串不放:不向消费方伪造一个空字段。
if reasoning != "" {
payload["reasoning_content"] = reasoning
}
// ★ 记忆候选是**不经 context** 的另一条持久化侧路(走 EmitOutput)。
// 不在这里拦,明文会直接进长期记忆 —— 而记忆跨会话保留、每轮被召回。
a.scrubMemoryCandidate(payload)
@ -657,11 +671,22 @@ func coldcontextOriginalText(coldDoc *coldcontext.ColdDoc) string {
}
var b strings.Builder
for _, ev := range coldDoc.Events {
in, resp, _ := ev.InOut()
// ★ 思考内容必须进蒸馏原文(2026-11-11 用户裁定)。
//
// 只取 Input/Response 会丢掉决策依据:"排除掉了 B,因为 B 的接口
// 不返回时间戳"这类判断就只在思考里。丢掉它,蒸馏出的三元组就只剩
// "选了 A",没有"为何不是 B"——而后者往往才是可复用的知识。
//
// 顺序按**时序**:输入 → 思考 → 回复。思考发生在回复之前。
in, resp, reasoning, _ := ev.InOutWithReasoning()
if in != "" {
b.WriteString(in)
b.WriteString("\n")
}
if reasoning != "" {
b.WriteString(reasoning)
b.WriteString("\n")
}
if resp != "" {
b.WriteString(resp)
b.WriteString("\n")

View File

@ -672,7 +672,21 @@ func (a *Agent) renderL1Messages(maxTokens int) []agentAPI.Message {
msgs = append(msgs, agentAPI.Message{Role: "user", Content: input})
}
if e.Response != "" || len(calls) > 0 {
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: e.Response, ToolCalls: calls})
// ★ ReasoningContent 必须跟着 assistant 消息一起回传(2026-11-11 生产故障)。
//
// DeepSeek thinking 模式:请求**带 tools** 时,所有历史轮的
// reasoning_content 都必须传回,否则 400(官方指南 thinking_mode)。
// 内核每轮都带工具定义,所以这里漏一个字段就会让会话永久不可用。
//
// omitempty 在 agentAPI.Message 上:空串不发该字段 ——
// 存量事件(本次修复前写入的 context.json)自然无该字段,
// 那种情况交给适配器兜底(见 deepseek.lua 的 transform_request)。
msgs = append(msgs, agentAPI.Message{
Role: "assistant",
Content: e.Response,
ReasoningContent: e.ReasoningContent,
ToolCalls: calls,
})
}
for _, tr := range e.ToolResults {
if tr.CallID != "" && !activeCallIDs[tr.CallID] {

View File

@ -180,6 +180,18 @@ type TaskFrame struct {
CurRaw interface{}
Resp *agentAPI.CompletionResponse
// TurnReasoning 是**本次任务里最后一次** LLM 响应的思考内容。
//
// ★ 为何不能靠 f.Resp.ReasoningContent:
//
// f.Resp 只在“本批有工具调用”时才赋值(stepLLM 里 len(resp.ToolCalls)==0
// 就 return 了)。纯文本收尾的那一轮 f.Resp 要么是 nil,要么还指着
// **上一次工具轮**的旧响应 —— 拿它写进 L1 会把上一批的思维链
// 冒名顶替成本轮的(比缺失更坏:模型会看到张冠李戴的推理)。
//
// 因此单独一个字段,由 stepLLM **无条件**写入(在分支之前)。
TurnReasoning string
// Scene 是本轮**涌现**出来的场景键(由场面指纹聚类得到,无人声明),
// sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景
// 加一次强度,「工具调得多」会被误当成「这个场面更常出现」。
@ -596,14 +608,18 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
//
// 声明来源:payload["no_memory"](插件注入)或 ChannelDef.NoMemory(通道)。
turnEvt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
NoMemory: f.StageCtx.NoMemory,
Input: f.CleanInput,
Response: f.Response,
ToolsUsed: f.ToolsUsed,
ToolResults: f.ToolResults,
ToolCalls: f.ToolCalls,
Timestamp: time.Now(),
Source: "agent",
NoMemory: f.StageCtx.NoMemory,
Input: f.CleanInput,
Response: f.Response,
// ★ 思维链必须进 L1:下一轮 renderL1Messages 要把它原样回传给上游。
// DeepSeek thinking 模式在**带 tools 的请求**里强制要求历史 reasoning_content,
// 而内核每轮都带工具定义 ⇒ 不存就永久 400。详见字段注释。
ReasoningContent: f.TurnReasoning,
ToolsUsed: f.ToolsUsed,
ToolResults: f.ToolResults,
ToolCalls: f.ToolCalls,
}
a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
// ★ 明文拦截发生在 a.context.Append **内部**(见 context.go 的 Append),
@ -613,7 +629,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
a.emitResponse(evt, f.Response, f.turnUsage)
if !f.StageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.ToolResults, f.ToolsUsed)
a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.TurnReasoning, f.ToolResults, f.ToolsUsed)
}
f.Terminal = terminalOK
}
@ -910,6 +926,11 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
})
}
// ★ 必须放在分支**之前**:下一行的 len(ToolCalls)==0 会直接 return,
// 那条路径也要记下 reasoning(否则纯文本收尾轮的思维链丢失,而它同样
// 属于“带 tools 的请求历史”,DeepSeek 照样要它)。
f.TurnReasoning = resp.ReasoningContent
if len(resp.ToolCalls) == 0 {
f.Response = resp.Content
return outcomeDone

View File

@ -1,11 +1,13 @@
package test
import (
"github.com/JianFeeeee/HologramAgent/internal/agent/core"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/agent/core"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
@ -249,3 +251,62 @@ func TestStageHostToolDefNoMemoryStored(t *testing.T) {
t.Error("normal_tool.NoMemory should be false")
}
}
// TestRunStageCollectsHandlerErrors:handler 返回的错误必须汇总到
// ctx.Errors,且不阻断其他 handler(模块级失败语义)。
func TestRunStageCollectsHandlerErrors(t *testing.T) {
h := core.NewStageHost()
h.RegisterStage(sdk.StagePreAction, func(*sdk.StageContext) error {
return fmt.Errorf("boom-from-handler")
})
h.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error {
kept := "kept"
ctx.Lock()
ctx.Response = &kept
ctx.Unlock()
return nil
})
ctx := &sdk.StageContext{}
h.RunStage(sdk.StagePreAction, ctx)
if len(ctx.Errors) != 1 || ctx.Errors[0] != "boom-from-handler" {
t.Fatalf("errors = %v", ctx.Errors)
}
ctx.RLock()
defer ctx.RUnlock()
if ctx.Response == nil || *ctx.Response != "kept" {
t.Fatalf("healthy handler output lost: %v", ctx.Response)
}
}
// TestRunStagePanicDoesNotKillRun:handler panic 必须被隔离,
// 其余 handler 照常执行且 RunStage 正常返回。
func TestRunStagePanicDoesNotKillRun(t *testing.T) {
h := core.NewStageHost()
h.RegisterStage(sdk.StageAfterOutput, func(*sdk.StageContext) error { panic("handler exploded") })
var ran int
h.RegisterStage(sdk.StageAfterOutput, func(*sdk.StageContext) error { ran++; return nil })
h.RunStage(sdk.StageAfterOutput, &sdk.StageContext{})
if ran != 1 {
t.Fatalf("healthy handler skipped after a panic: ran=%d", ran)
}
}
// TestUnregisterPluginToolsOnlyThatPlugin:工具摘除必须只影响目标插件。
func TestUnregisterPluginToolsOnlyThatPlugin(t *testing.T) {
h := core.NewStageHost()
for _, name := range []string{"a_tool", "b_tool"} {
pluginName := strings.SplitN(name, "_", 2)[0]
h.RegisterTool(name, sdk.ToolDef{Name: name, Plugin: pluginName},
func(map[string]interface{}) (interface{}, error) { return "ok", nil })
}
h.UnregisterPluginTools("a")
if h.HasTool("a_tool") {
t.Fatal("unregistered plugin tool still executing")
}
if !h.HasTool("b_tool") {
t.Fatal("unrelated plugin tool was removed")
}
if _, err := h.ExecuteTool("b_tool", nil); err != nil {
t.Fatalf("surviving tool broke: %v", err)
}
}

View File

@ -1464,12 +1464,16 @@ func (a *Agent) restoreDocEvents(d *coldcontext.ColdDoc, inactivePages []int) in
n, blocks := 0, 0
for _, ev := range d.Events {
input, response, held := ev.InOut()
// ★ 用 InOutWithReasoning:回灌要完整还原事件,包括思考内容。
// 只用 InOut 会让回填出的 ContextEvent 缺 ReasoningContent,
// 于是再次被裁下时思维链就永久丢了(而且 L1 里那份已经是唯一副本)。
input, response, reasoning, held := ev.InOutWithReasoning()
a.context.InsertByTimestamp(ContextEvent{
Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置
Source: ev.Source,
Input: input,
Response: response,
Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置
Source: ev.Source,
Input: input,
Response: response,
ReasoningContent: reasoning,
// ★ 回填事件**不参与记忆计算**,但**照常参与归档**。
//
// 为什么要标记:它们的内容刚在 L2 里被向量化过一次,

View File

@ -1,46 +1,158 @@
package test
// internal/agent/io 的黑盒判据:OutputCapability 的导出方法,输入 → 输出。
// 不构造设备、不碰内部注册表。
// internal/agent/io 的模块级黑盒判据:IOManager 的输入注入 → 可观察事件,
// 设备注册/查询/生命周期,以及能力标志语义。
import (
"errors"
"strings"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// TestSupportsSingleAndCombinedFlags:位标志的 Supports 语义。
type contractDevice struct {
name string
started int
stopped int
startErr error
}
func (d *contractDevice) Name() string { return d.name }
func (d *contractDevice) Type() io.DeviceType { return io.DeviceIO }
func (d *contractDevice) ChannelDef() io.ChannelDef { return io.ChannelDef{} }
func (d *contractDevice) Description() string { return "contract device" }
func (d *contractDevice) Tools() []io.ToolDef { return nil }
func (d *contractDevice) Execute(string, map[string]interface{}) (interface{}, error) {
return nil, nil
}
func (d *contractDevice) Start() error { d.started++; return d.startErr }
func (d *contractDevice) Stop() error { d.stopped++; return nil }
func (d *contractDevice) OutputCapabilities() io.OutputCapability { return io.CapText }
func receiveInput(t *testing.T, m *io.IOManager) *io.InputEvent {
t.Helper()
select {
case evt := <-m.InputChan():
return evt
case <-time.After(2 * time.Second):
t.Fatal("injected input never reached the module's input queue")
return nil
}
}
// TestInjectTextDeliversInputEvent:注入文本必须成为一条可观察的输入事件。
func TestInjectTextDeliversInputEvent(t *testing.T) {
m := io.NewIOManager()
m.InjectText("qq", "你好")
evt := receiveInput(t, m)
if evt.Source != "qq" || evt.OutputChannel != "qq" {
t.Fatalf("event routing = %+v", evt)
}
if text, _ := evt.Payload["content"].(string); text != "你好" {
t.Fatalf("payload text = %v", evt.Payload)
}
if evt.RequestID == "" {
t.Fatal("injected event has no request id")
}
}
// TestInjectInputToRoutesToRequestedChannel:显式输出通道必须被保留。
func TestInjectInputToRoutesToRequestedChannel(t *testing.T) {
m := io.NewIOManager()
m.InjectInputTo("qq", "webui", "message", map[string]interface{}{"text": "hi"})
evt := receiveInput(t, m)
if evt.Source != "qq" || evt.OutputChannel != "webui" {
t.Fatalf("routing = %+v", evt)
}
}
// TestSyncInjectionWaitsForResponse:同步注入必须把响应交回调用方。
func TestSyncInjectionWaitsForResponse(t *testing.T) {
m := io.NewIOManager()
go func() {
evt := <-m.InputChan()
if evt.ResponseCh != nil {
evt.ResponseCh <- &io.OutputEvent{Payload: map[string]interface{}{"content": "answer"}}
}
}()
got := m.InjectTextSync("qq", "问题")
if got == nil {
t.Fatal("sync injection returned nil")
}
if c, _ := got.Payload["content"].(string); c != "answer" {
t.Fatalf("response payload = %v", got.Payload)
}
}
// TestDeviceRegistrationLifecycle:注册 → 查询 → StartAll/StopAll 真实生效。
func TestDeviceRegistrationLifecycle(t *testing.T) {
m := io.NewIOManager()
dev := &contractDevice{name: "contract-dev"}
if err := m.RegisterDevice(dev); err != nil {
t.Fatalf("RegisterDevice: %v", err)
}
if got := m.GetDevice("contract-dev"); got == nil || got.Name() != "contract-dev" {
t.Fatalf("GetDevice = %v", got)
}
if err := m.StartAll(); err != nil {
t.Fatalf("StartAll: %v", err)
}
m.StopAll()
if dev.started != 1 || dev.stopped != 1 {
t.Fatalf("device lifecycle started=%d stopped=%d", dev.started, dev.stopped)
}
}
// TestDuplicateDeviceRejected:重复注册必须报错,不静默覆盖。
func TestDuplicateDeviceRejected(t *testing.T) {
m := io.NewIOManager()
if err := m.RegisterDevice(&contractDevice{name: "dup"}); err != nil {
t.Fatal(err)
}
if err := m.RegisterDevice(&contractDevice{name: "dup"}); err == nil {
t.Fatal("duplicate device name accepted")
}
}
// TestStartAllPropagatesDeviceFailure:设备启动失败必须向上传播。
func TestStartAllPropagatesDeviceFailure(t *testing.T) {
m := io.NewIOManager()
dev := &contractDevice{name: "bad", startErr: errors.New("boom")}
if err := m.RegisterDevice(dev); err != nil {
t.Fatal(err)
}
err := m.StartAll()
if err == nil || !strings.Contains(err.Error(), "bad") {
t.Fatalf("StartAll error = %v", err)
}
}
// TestSupportsSingleAndCombinedFlags:位标志的 Supports 语义(任一位命中)。
func TestSupportsSingleAndCombinedFlags(t *testing.T) {
c := io.CapText | io.CapAudio
if !c.Supports(io.CapText) || !c.Supports(io.CapAudio) {
t.Fatal("★ 已置位的能力报告不支持")
t.Fatal("set capability reported as unsupported")
}
if c.Supports(io.CapImage) {
t.Fatal("★ 未置位的能力报告支持")
t.Fatal("unset capability reported as supported")
}
// Supports 的语义是「任一位命中」,不是「全部命中」。
if !io.CapText.Supports(io.CapText | io.CapImage) {
t.Fatal("★ 任一位命中应报告支持")
t.Fatal("any-bit semantics broken")
}
if io.CapText.Supports(io.CapImage) {
t.Fatal("★ 完全不相交却报告支持")
t.Fatal("disjoint capability reported supported")
}
}
// TestCapabilityStringListsFlags:String 输出列出已置位的能力名。
// TestCapabilityStringListsFlags:String 列出已置位能力名。
func TestCapabilityStringListsFlags(t *testing.T) {
c := io.CapText | io.CapImage
s := c.String()
s := (io.CapText | io.CapImage).String()
if !strings.Contains(s, "text") || !strings.Contains(s, "image") {
t.Fatalf("★ String() = %q,缺已置位能力名", s)
t.Fatalf("String() = %q", s)
}
if strings.Contains(s, "audio") {
t.Fatalf("★ String() = %q,含未置位能力", s)
t.Fatalf("String() = %q contains unset capability", s)
}
}
// TestZeroCapabilityString:零能力字符串可用(不 panic)。
func TestZeroCapabilityString(t *testing.T) {
_ = io.OutputCapability(0).String()
}

View File

@ -837,6 +837,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) bool {
}
set("core.plugin.dir", filepath.Join(dataDir, "plugins"))
set("core.plugin.shm_arena_mb", "4")
set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db"))
set("core.memory.text", filepath.Join(dataDir, "memory", "text"))
set("core.memory.documents", filepath.Join(dataDir, "memory", "documents"))
@ -930,6 +931,23 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) {
reg(ConfigDef{Key: "core.llm.max_tokens", Default: "4096", Type: "int", DisplayName: "最大 Token", Description: "每次生成的最大 Token 数", Category: "llm"})
reg(ConfigDef{Key: "core.llm.thinking_enabled", Default: "false", Type: "bool", DisplayName: "深度思考", Description: "启用深度思考模式(如 DeepSeek R1 的思维链输出)", Category: "llm"})
// ★ 适配器同步策略(2026-11-11)。
//
// `adapters/` 下的 .lua 是**内核自带**的协议适配层,会随内核版本修复而变。
// 但那是用户盘上的文件,内核该不该动它?默认**不动**(off)——
// 擅自改用户文件比“落后一个版本”更糟,且无法区分“旧发行版残留”
// 与“用户手工改过”(两者在文件内容上长得一模一样)。
//
// off 时:不覆盖,但**必须响亮告警**(告警做得不准等于没告警:
// 本机实测 8 个适配器落后 3000~6500 字节,日志只说“保留用户版本”,
// 完全看不出是落后)。
// auto 时:差异即备份为 `<name>.lua.bak-<hash8>` 然后覆盖,永不会卡在旧版。
reg(ConfigDef{Key: "core.lua.adapter_sync", Default: "off", Type: "string",
DisplayName: "适配器自动同步",
Description: "盘上适配器与内置版不一致时的处理:off(默认)只告警不改文件;auto 备份后自动覆盖。" +
"需要长期定制某个源,请改对应的 core.llm.sources.<name>.adapter_path,不要改内置同名文件。",
Category: "llm"})
for name := range defaultSources {
p := "core.llm.sources." + name
reg(ConfigDef{Key: p + ".base_url", Default: defaultSources[name]["base_url"], Type: "string", DisplayName: name + " API 地址", Description: name + " LLM API 基础地址", Category: "sources"})
@ -957,6 +975,11 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) {
plgDir := filepath.Join(dataDir, "plugins")
reg(ConfigDef{Key: "core.plugin.dir", Default: plgDir, Type: "string", DisplayName: "插件目录", Description: "外部插件安装目录", Category: "paths"})
reg(ConfigDef{Key: "core.plugin.shm_arena_mb", Default: "4", Type: "int", DisplayName: "插件共享内存(MB)",
Description: "子进程插件共用的共享段 arena 容量(1–256MB)。" +
"段大小在启动时一次确定,**修改后必须重启内核才生效**;" +
"改大不会立刻占物理内存(memfd 惰性分配),但会扩大虚拟地址空间占用。",
Category: "plugin"})
reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"})
reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})

View File

@ -4,6 +4,8 @@ package test
// 输入 → 输出。不读源码、不碰 registry 内部结构。
import (
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/config"
@ -106,28 +108,60 @@ func TestDefRoundTripPreservesSecret(t *testing.T) {
}
}
// TestSecretValueIsNotPlaintextOnDisk:SetSecret → GetSecret 往返,且 DB 里无明文。
func TestSecretValueIsNotPlaintextOnDisk(t *testing.T) {
reg := newRegistry(t)
reg.SetCodec(config.NewTestCodec(t))
p := reg.PluginConfig("alpha")
// TestSecretSurvivesRestartAndStaysEncrypted 覆盖配置模块的完整职责:
// 敏感项写入 → 磁盘上无明文 → 关闭并重新打开同一数据库后仍可正确解密读回。
// 只用公开 API 观察,不查询内部表结构。
func TestSecretSurvivesRestartAndStaysEncrypted(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.db")
first := config.NewConfigRegistry(path)
first.SetCodec(config.NewTestCodec(t))
p := first.PluginConfig("alpha")
p.RegisterDef(config.ConfigDef{Key: "api_key", Secret: true})
if err := p.Set("api_key", "PLAINTEXT-SECRET-123"); err != nil {
t.Fatal(err)
const secret = "PLAINTEXT-SECRET-123"
if err := p.Set("api_key", secret); err != nil {
first.Close()
t.Fatalf("Set: %v", err)
}
got, err := p.GetSecret("api_key")
if got, err := p.GetSecret("api_key"); err != nil || got != secret {
first.Close()
t.Fatalf("GetSecret before restart = %q / %v", got, err)
}
if err := first.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// 落盘文件里不得出现明文。
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("GetSecret: %v", err)
}
if got != "PLAINTEXT-SECRET-123" {
t.Fatalf("★ 解密后 = %q,期望原文", got)
}
var raw string
if err := reg.DB().QueryRow(
"SELECT value FROM config_alpha WHERE key='api_key'").Scan(&raw); err != nil {
t.Fatal(err)
}
if raw == "PLAINTEXT-SECRET-123" {
t.Fatal("★ 磁盘上是明文 —— 敏感项没有加密")
if string(raw) == secret || containsBytes(raw, []byte(secret)) {
t.Fatalf("secret written in plaintext on disk")
}
second := config.NewConfigRegistry(path)
defer second.Close()
second.SetCodec(config.NewTestCodec(t))
q := second.PluginConfig("alpha")
if got, err := q.GetSecret("api_key"); err != nil || got != secret {
t.Fatalf("GetSecret after restart = %q / %v", got, err)
}
}
func containsBytes(haystack, needle []byte) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
match := true
for j := range needle {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}

View File

@ -4,6 +4,7 @@ package test
// 只调导出方法,不碰 b.subs、不读源码。
import (
"errors"
"testing"
"time"
@ -96,3 +97,88 @@ func TestSetGuardRoundTrip(t *testing.T) {
t.Fatal("★ Guard() 没拿回 SetGuard 设置的守卫")
}
}
// denyGuard 拒绝来自指定发布者的一切发布。
type denyGuard struct {
denyPublisher string
scrubTo string
}
func (g *denyGuard) AllowPublish(p events.Publisher, et events.EventType) (bool, string) {
if p.Name == g.denyPublisher {
return false, "policy forbids " + p.Name
}
return true, ""
}
func (g *denyGuard) AllowSubscribe(events.Publisher, events.EventType) (bool, string) {
return true, ""
}
func (g *denyGuard) ScrubPayload(et events.EventType, m map[string]interface{}) map[string]interface{} {
return m
}
func (g *denyGuard) ScrubInbound(et events.EventType, m map[string]interface{}) map[string]interface{} {
if g.scrubTo != "" {
for k := range m {
m[k] = g.scrubTo
}
}
return m
}
// TestPublishAsReturnsDeniedErrorAndBlocksDelivery:被策略拒绝的发布必须
// 返回可诊断错误,且订阅者收不到任何东西。
func TestPublishAsReturnsDeniedErrorAndBlocksDelivery(t *testing.T) {
bus := events.NewBus()
bus.SetGuard(&denyGuard{denyPublisher: "bad-plugin"})
var delivered int
bus.Subscribe(events.EventType("contract.denied"), func(*events.Event) { delivered++ })
err := bus.PublishAs(events.Publisher{Name: "bad-plugin"},
&events.Event{Type: events.EventType("contract.denied"), Payload: map[string]interface{}{"x": 1}})
if err == nil {
t.Fatal("denied publish returned nil error")
}
var denied *events.DeniedError
if !errors.As(err, &denied) || denied.Publisher != "bad-plugin" {
t.Fatalf("error is not a diagnosable DeniedError: %v", err)
}
if delivered != 0 {
t.Fatalf("denied event still reached subscribers: %d", delivered)
}
}
func TestSubscribersReceiveScrubbedPayload(t *testing.T) {
bus := events.NewBus()
bus.SetGuard(&denyGuard{scrubTo: "[redacted]"})
got := make(chan map[string]interface{}, 1)
bus.Subscribe(events.EventType("contract.scrub"), func(e *events.Event) { got <- e.Payload })
if err := bus.PublishAs(events.Publisher{Name: "p"},
&events.Event{Type: events.EventType("contract.scrub"),
Payload: map[string]interface{}{"token": "SECRET", "x": 1}}); err != nil {
t.Fatalf("publish: %v", err)
}
select {
case p := <-got:
for k, v := range p {
if v == "SECRET" {
t.Fatalf("plaintext secret reached subscriber: %v", p)
}
_ = k
}
case <-time.After(2 * time.Second):
t.Fatal("subscriber never received the event")
}
}
func TestHandlerPanicDoesNotBreakOtherSubscribers(t *testing.T) {
bus := events.NewBus()
var ran int
bus.Subscribe(events.EventType("contract.panic"), func(*events.Event) { panic("boom") })
bus.Subscribe(events.EventType("contract.panic"), func(*events.Event) { ran++ })
bus.Publish(&events.Event{Type: events.EventType("contract.panic")})
if ran != 1 {
t.Fatalf("healthy subscriber skipped after panic: ran=%d", ran)
}
}

View File

@ -21,6 +21,17 @@ func (Module) Provides() []string { return []string{kernelmod.FacLua} }
func (Module) Init(ks *kstate.State) error {
luaVM := luapkg.NewVM(filepath.Join(ks.Cfg.Daemon.DataDir, "adapters"))
// 适配器同步策略(core.lua.adapter_sync,默认 off)。
//
// 读不到就保持零值 = off(保守):忘记注入时宁可「不动用户文件」,
// 也不能默默把用户的 adapters/ 全量覆盖一遍。
if ks.CfgReg != nil {
if v, err := ks.CfgReg.Get("core.lua.adapter_sync"); err == nil && v != nil {
if s, ok := v.(string); ok {
luaVM.SetAdapterSync(s)
}
}
}
if err := luaVM.Start(); err != nil {
log.Printf("[homed] warning: lua vm init failed: %v", err)
ks.LuaVM = luaVM

View File

@ -56,11 +56,63 @@ function adapter.transform_request(raw_body)
req.model = req.model or "deepseek-chat"
req.stream = req.stream or false
-- ── thinking 开关:必须是**顶层**字段,不能包在 extra_body 里 ──
--
-- 官方文档示例全用 OpenAI SDK:extra_body={"thinking": {...}}
-- 那是 **SDK 层参数** —— SDK 会把它摊平进 HTTP 请求体顶层,
-- 真实发出去的是 {"thinking": {...}}。而本适配器是把 JSON **裸发**给
-- api.deepseek.com 的,再包一层 extra_body 服务端不认识,直接忽略
-- ⇒ thinking 保持默认(**开启**)⇒ 带 tools 的请求就要求回传
-- reasoning_content,否则 400。
--
-- 修正前写的是 req.extra_body.thinking,所以即使内核
-- thinking_enabled=false(发出 disable_thinking=true)也从未真正关掉思考。
--
-- 为何值得关:deepseek-flash 的 thinking 默认开启,显式关闭省掉整段 CoT
-- 的 token;若用户确实要思考,就不会配 thinking_enabled=false。
if req.disable_thinking then
req.extra_body = req.extra_body or {}
req.extra_body.thinking = { type = "disabled" }
req.thinking = { type = "disabled" }
end
req.disable_thinking = nil
-- 内核从不发 extra_body;万一将来发了,也不该把 SDK 层的壳透传给上游。
req.extra_body = nil
-- ── reasoning_content 回传兜底(2026-11-11 生产故障)──
--
-- 上游报:
-- all 2 providers failed, last error: api error 400:
-- "The reasoning_content in the thinking mode must be passed back to the API."
--
-- DeepSeek 官方规则(api-docs.deepseek.com/guides/thinking_mode):
-- 请求**带 tools** 时,所有历史轮的 reasoning_content 都必须传回,
-- 并会拼进上下文;不带 tools 时不要求。
--
-- 内核侧已修(ContextEvent.ReasoningContent + renderL1Messages 回传),
-- 但**存量会话**的 context.json 是在修复前写的,那些事件根本没这个字段。
-- 不补就是个死结:历史缺字段 ⇒ 400 ⇒ 会话永久不可用(重启也救不回来,
-- 因为缺的字段在磁盘上的旧记录里)。
--
-- 修法:给**每一条 assistant 消息**补齐该字段(缺失时补空串)。
-- 为什么空串是合法的:字段的语义是"上一轮的思考内容",空串如实表达
-- "这一轮没有可回传的思考"(其他主流实现的同款修复也是补空串:
-- langchain #37179、n8n #34924、opencode #24722)。
--
-- 只给 assistant 补:system/user/tool 消息不接受该字段,乱发可能被拒。
if type(req.messages) == "table" then
local has_tools = type(req.tools) == "table" and #req.tools > 0
for _, m in ipairs(req.messages) do
if type(m) == "table" and m.role == "assistant" and m.reasoning_content == nil then
-- 只在带 tools 的请求里补(无 tools 时上游不要求,也不想平白
-- 往上下文里塞一个空字段)。但如果模型名显式是 reasoner,
-- 它可能无条件校验,所以两条都补。
if has_tools or string.find(tostring(req.model), "reasoner", 1, true) ~= nil then
m.reasoning_content = ""
end
end
end
end
return json.encode(req)
end

View File

@ -0,0 +1,289 @@
package test
// 适配器同步策略判据(2026-11-11)。
//
// ## 背景:一个真实事故
//
// `adapters/*.lua` 落在**用户的数据目录**里,但它们是内核自带的协议适配层,
// 会随内核修复而变。内核该不该动用户盘上的文件?
//
// 本机实测的现状:8 个适配器(openai/anthropic/github/groq/mistral/ollama/
// kimicode/server)落后内置版 3000~6500 字节,缺的是用量归一化
// (cache_read / reasoning_tokens)、流式 tool_calls 等真实修复。
// 而日志只说一句「保留用户版本」——看起来像用户自己的选择,
// 完全看不出是「永久落后」,症状最终表现为「模型不听话」「工具调不动」。
//
// ## 为什么默认 off
//
// 「旧发行版残留」与「用户手工改过」在文件内容上**长得一模一样**:
// 两者都只是「hash 不等于内置版」。既然无法区分,就不该猜 ——
// 擅自覆盖用户文件比落后一个版本更糟。
//
// 所以:
//
// off(默认) 不碰文件,但告警必须**可操作**(列出哪些、说明影响、给处理办法)
// auto 差异即备份为 `<name>.lua.bak-<hash8>` 后覆盖,永不落后
//
// 用户要长期定制有正道:`core.llm.sources.<name>.adapter_path` 是每源独立可配的。
//
// ## 判据怎么读
//
// AdapterDir() 与 SetAdapterSync()/AdapterSyncMode() 都是公开的,
// 完全黑盒:起 VM → 读文件系统 → 断言。
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/lua"
)
func readManifest(t *testing.T, dir string) map[string]string {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, ".bundled"))
if err != nil {
t.Fatalf("读清单: %v", err)
}
out := map[string]string{}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) != 2 {
t.Fatalf("清单行格式异常: %q", line)
}
out[parts[0]] = parts[1]
}
return out
}
func hashFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s: %v", path, err)
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func countBackups(t *testing.T, dir string) int {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("readdir: %v", err)
}
var n int
for _, e := range entries {
if strings.Contains(e.Name(), ".bak-") {
n++
}
}
return n
}
// drift 造一次「盘上与内置不一致」,返回漂移后的 hash。
func drift(t *testing.T, dir, name string) string {
t.Helper()
target := filepath.Join(dir, name+".lua")
orig, err := os.ReadFile(target)
if err != nil {
t.Fatalf("读 %s: %v", target, err)
}
if err := os.WriteFile(target, append([]byte("-- drifted (旧发行版残留)\n"), orig...), 0644); err != nil {
t.Fatalf("写: %v", err)
}
return hashFile(t, target)
}
// 判据:默认模式必须是 off。
//
// 这条钉的是「忘记注入配置时的行为」——必须是保守的,不能默认覆盖用户文件。
func TestAdapterSyncDefaultsToOff(t *testing.T) {
v := lua.NewVM(t.TempDir())
if got := v.AdapterSyncMode(); got != "off" {
t.Fatalf("★ 默认模式 = %q,期望 off(默认覆盖用户文件太危险)", got)
}
// 大小写/空白不应影响判定
v.SetAdapterSync(" AUTO ")
if got := v.AdapterSyncMode(); got != "auto" {
t.Fatalf("★ 大小写归一化失效:%q", got)
}
}
// 判据:off 模式下**不碰**用户的适配器文件。
//
// 变异验证(实测):把 off 分支改成也覆盖 ⇒ 本判据红。
func TestOffModeLeavesDiskUntouched(t *testing.T) {
dir := t.TempDir()
v1 := lua.NewVM(dir) // 默认 off
if err := v1.Start(); err != nil {
t.Fatalf("首次 Start: %v", err)
}
v1.Stop()
want := drift(t, dir, "openai")
v2 := lua.NewVM(dir)
if err := v2.Start(); err != nil {
t.Fatalf("二次 Start: %v", err)
}
v2.Stop()
if got := hashFile(t, filepath.Join(dir, "openai.lua")); got != want {
t.Fatal("★ off 模式仍改动了盘上文件")
}
if n := countBackups(t, dir); n != 0 {
t.Fatalf("★ off 模式产生了 %d 个备份(说明它动过文件)", n)
}
}
// 判据:auto 模式下,差异必须被更新,且留下可回滚的备份。
//
// 这条钉的是「永不落后」这条路:选 auto 的人要的是跟随内核。
func TestAutoModeUpdatesWithBackup(t *testing.T) {
dir := t.TempDir()
v1 := lua.NewVM(dir)
if err := v1.Start(); err != nil {
t.Fatalf("首次 Start: %v", err)
}
v1.Stop()
driftHash := drift(t, dir, "groq")
driftedContent, _ := os.ReadFile(filepath.Join(dir, "groq.lua"))
v2 := lua.NewVM(dir)
v2.SetAdapterSync("auto")
if err := v2.Start(); err != nil {
t.Fatalf("二次 Start: %v", err)
}
v2.Stop()
now := hashFile(t, filepath.Join(dir, "groq.lua"))
if now == driftHash {
t.Fatal("★ auto 模式下盘上仍是旧版")
}
// 旧内容必须能在备份里找到(不无声丢改动)
bak := filepath.Join(dir, "groq.lua.bak-"+driftHash[:8])
old, err := os.ReadFile(bak)
if err != nil {
t.Fatalf("★ 没留下备份(用户改动被无声丢弃): %v", err)
}
if string(old) != string(driftedContent) {
t.Fatal("★ 备份内容与覆盖前不一致")
}
}
// 判据:auto 模式下,与内置一致时不得重复备份(否则每次启动堆一个 .bak)。
func TestAutoModeNoRedundantBackup(t *testing.T) {
dir := t.TempDir()
for i := 0; i < 3; i++ {
v := lua.NewVM(dir)
v.SetAdapterSync("auto")
if err := v.Start(); err != nil {
t.Fatalf("第 %d 次 Start: %v", i+1, err)
}
v.Stop()
}
if n := countBackups(t, dir); n != 0 {
t.Fatalf("★ 无漂移却产生 %d 个备份(每次启动都会堆积)", n)
}
}
// 判据:反复启停后状态稳定(两种模式都不该在启停间漂移)。
//
// 这是旧设计的另一半症状:清单基线被来回改写,导致每次启动都重判一遍。
func TestAdapterStateStableAcrossRestarts(t *testing.T) {
for _, mode := range []string{"off", "auto"} {
dir := t.TempDir()
v := lua.NewVM(dir)
v.SetAdapterSync(mode)
if err := v.Start(); err != nil {
t.Fatalf("[%s] Start: %v", mode, err)
}
v.Stop()
drift(t, dir, "mistral")
var prev string
for i := 0; i < 3; i++ {
vv := lua.NewVM(dir)
vv.SetAdapterSync(mode)
if err := vv.Start(); err != nil {
t.Fatalf("[%s] 第 %d 次 Start: %v", mode, i+1, err)
}
vv.Stop()
m := readManifest(t, dir)
cur := ""
for _, name := range []string{"openai", "groq", "mistral", "deepseek"} {
disk := hashFile(t, filepath.Join(dir, name+".lua"))
if m[name] != disk {
t.Fatalf("[%s] 第 %d 次启动后 %s 清单与盘上不一致", mode, i+1, name)
}
cur += m[name]
}
if prev != "" && cur != prev {
t.Fatalf("[%s] 清单在启停间漂移(第 %d 次)", mode, i+1)
}
prev = cur
}
}
}
// 判据:清单覆盖全部落盘的适配器(漏记 = 那个适配器永远不会被体检到)。
func TestManifestCoversAllBundledAdapters(t *testing.T) {
dir := t.TempDir()
v := lua.NewVM(dir)
if err := v.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer v.Stop()
m := readManifest(t, dir)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("readdir: %v", err)
}
var n int
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".lua") {
continue
}
n++
name := strings.TrimSuffix(e.Name(), ".lua")
if _, ok := m[name]; !ok {
t.Errorf("★ 适配器 %s 落盘了但没进清单", name)
}
}
if n == 0 {
t.Fatal("★ 没有落盘任何适配器")
}
}
// 判据:首次安装(盘上无文件)必须落盘,两种模式都一样。
//
// 反向保护:off 的语义是「不动**已存在**的用户文件」,不是「不安装」。
func TestFirstInstallHappensInBothModes(t *testing.T) {
for _, mode := range []string{"off", "auto"} {
dir := t.TempDir()
v := lua.NewVM(dir)
v.SetAdapterSync(mode)
if err := v.Start(); err != nil {
t.Fatalf("[%s] Start: %v", mode, err)
}
v.Stop()
for _, name := range []string{"openai", "deepseek", "server"} {
if _, err := os.Stat(filepath.Join(dir, name+".lua")); err != nil {
t.Fatalf("[%s] 首次安装缺 %s.lua", mode, name)
}
}
}
}

View File

@ -0,0 +1,261 @@
package test
// deepseek 适配器的 reasoning_content 回传判据(2026-11-11)。
//
// ## 钉的故障
//
// all 2 providers failed, last error: api error 400:
// "The reasoning_content in the thinking mode must be passed back to the API."
//
// DeepSeek 规则:请求带 tools 时,所有历史轮的 reasoning_content 都要传回。
//
// 内核侧三层已修(L1 存+回传、L2 落块、L3 进蒸馏),但**存量会话**的
// context.json 是修复前写的,那些历史事件根本没有该字段。不兜底就是死结:
// 历史缺字段 ⇒ 400 ⇒ 会话永久不可用(重启也救不回来,缺的字段在磁盘上)。
//
// 所以适配器必须补字段。本文件钉住"兜底补得对、且不覆盖真实值"。
//
// ## 为什么要补**空串**而不是跳过
//
// 字段语义是"上一轮的思考内容"。空串如实表达"这一轮没有可回传的思考"。
// 其他主流实现的同款修复也是补空串(langchain #37179、n8n #34924、
// opencode #24722)—— 上游接受,且比删掉整条消息更保真。
import (
"encoding/json"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/lua"
)
// newStartedVM 起一个加载了内置适配器的 VM(与运行时同一套落盘逻辑)。
func newStartedVM(t *testing.T) *lua.VM {
t.Helper()
v := lua.NewVM(t.TempDir())
if err := v.Start(); err != nil {
t.Fatalf("VM.Start: %v", err)
}
t.Cleanup(v.Stop)
return v
}
// transform 调真实内置适配器的 transform_request,返回解码后的请求体。
func transform(t *testing.T, v *lua.VM, body string) map[string]interface{} {
t.Helper()
out, err := v.CallTransformRequest("deepseek", body)
if err != nil {
t.Fatalf("CallTransformRequest: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("适配器输出不是合法 JSON: %v\n%s", err, out)
}
return got
}
// assistantMsgs 取请求里所有 assistant 消息。
func assistantMsgs(req map[string]interface{}) []map[string]interface{} {
var out []map[string]interface{}
msgs, _ := req["messages"].([]interface{})
for _, m := range msgs {
if mm, ok := m.(map[string]interface{}); ok && mm["role"] == "assistant" {
out = append(out, mm)
}
}
return out
}
// 判据:带 tools + assistant 缺 reasoning_content ⇒ 必须补上空串。
//
// 变异验证(实测):把 transform_request 里补字段那段删掉 ⇒ 本判据红。
func TestAdapterBackfillsReasoningWhenToolsPresent(t *testing.T) {
v := newStartedVM(t)
// 存量会话形态:assistant(tool_calls) 没有 reasoning_content
body := `{
"model":"deepseek-chat",
"tools":[{"type":"function","function":{"name":"sql_query","parameters":{"type":"object"}}}],
"messages":[
{"role":"user","content":"上个月账单什么时候出的?"},
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"sql_query","arguments":"{}"}}]},
{"role":"tool","tool_call_id":"c1","content":"12号"}
]
}`
got := transform(t, v, body)
as := assistantMsgs(got)
if len(as) != 1 {
t.Fatalf("★ assistant 消息数 = %d,期望 1", len(as))
}
if _, ok := as[0]["reasoning_content"]; !ok {
t.Fatalf("★ 带 tools 的请求里 assistant 缺 reasoning_content(上游会 400):%v", as[0])
}
// 补的是空串,不是凭空造内容
if s, _ := as[0]["reasoning_content"].(string); s != "" {
t.Errorf("★ 兜底应补空串,实际 %q(不得伪造思考内容)", s)
}
}
// 判据:**不能覆盖**内核已经带来的真实思考内容。
//
// 这条比上面那条更要紧:覆盖 = 把真实思维链换成空串,
// 上游虽然不报 400 了,但多轮上下文里的推理链条被抹掉,
// 属于"错误从显式变隐式"。
func TestAdapterDoesNotOverwriteRealReasoning(t *testing.T) {
v := newStartedVM(t)
const realThinking = "先确认问的是账单不是订单"
body := `{
"model":"deepseek-chat",
"tools":[{"type":"function","function":{"name":"sql_query","parameters":{"type":"object"}}}],
"messages":[
{"role":"user","content":"问"},
{"role":"assistant","content":"答","reasoning_content":"` + realThinking + `"}
]
}`
got := transform(t, v, body)
as := assistantMsgs(got)
if len(as) != 1 {
t.Fatalf("assistant 数 = %d", len(as))
}
if s, _ := as[0]["reasoning_content"].(string); s != realThinking {
t.Fatalf("★ 真实思考被覆盖:%q(期望 %q)", s, realThinking)
}
}
// 判据:不带 tools 时不补(上游不要求,也不必给上下文塞空字段)。
func TestAdapterLeavesReasoningAloneWithoutTools(t *testing.T) {
v := newStartedVM(t)
body := `{
"model":"deepseek-chat",
"messages":[
{"role":"user","content":"你好"},
{"role":"assistant","content":"你好,有什么可以帮你"}
]
}`
got := transform(t, v, body)
as := assistantMsgs(got)
if len(as) != 1 {
t.Fatalf("assistant 数 = %d", len(as))
}
if _, ok := as[0]["reasoning_content"]; ok {
t.Fatalf("★ 无 tools 时不该补该字段:%v", as[0])
}
}
// 判据:多轮历史里**每一条** assistant 都要有,不能只补最后一条。
//
// DeepSeek 的要求是"所有历史轮"。只补最后一条会让第二次工具回环
// 再次 400 —— 而那种"修一次好一次、第三轮又坏"最难查。
func TestAdapterBackfillsEveryAssistantTurn(t *testing.T) {
v := newStartedVM(t)
body := `{
"model":"deepseek-chat",
"tools":[{"type":"function","function":{"name":"t","parameters":{"type":"object"}}}],
"messages":[
{"role":"user","content":"a"},
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"t","arguments":"{}"}}]},
{"role":"tool","tool_call_id":"c1","content":"r1"},
{"role":"user","content":"b"},
{"role":"assistant","content":"","tool_calls":[{"id":"c2","type":"function","function":{"name":"t","arguments":"{}"}}]},
{"role":"tool","tool_call_id":"c2","content":"r2"}
]
}`
got := transform(t, v, body)
as := assistantMsgs(got)
if len(as) != 2 {
t.Fatalf("assistant 数 = %d,期望 2", len(as))
}
for i, m := range as {
if _, ok := m["reasoning_content"]; !ok {
t.Errorf("★ 第 %d 条 assistant 缺 reasoning_content(要补全部历史轮)", i+1)
}
}
}
// 判据:适配器不能顺手弄坏别的字段(tools / messages / model)。
//
// 反向保护:兜底逻辑若写成"重建整个请求体",很容易漏字段。
// 这条钉住"只补一个字段,其余原样"。
func TestAdapterPreservesOtherFields(t *testing.T) {
v := newStartedVM(t)
body := `{
"model":"deepseek-chat",
"temperature":0.7,
"max_tokens":1024,
"stream":true,
"tools":[{"type":"function","function":{"name":"t","parameters":{"type":"object"}}}],
"messages":[{"role":"user","content":"hi"}]
}`
got := transform(t, v, body)
if got["model"] != "deepseek-chat" {
t.Errorf("★ model 被改:%v", got["model"])
}
if temp, _ := got["temperature"].(float64); temp != 0.7 {
t.Errorf("★ temperature 被改:%v", got["temperature"])
}
if mt, _ := got["max_tokens"].(float64); mt != 1024 {
t.Errorf("★ max_tokens 被改:%v", got["max_tokens"])
}
if got["stream"] != true {
t.Errorf("★ stream 被改:%v", got["stream"])
}
if _, ok := got["tools"]; !ok {
t.Error("★ tools 丢了")
}
// 无思考内容的 user 消息不该被加上该字段
msgs, _ := got["messages"].([]interface{})
for _, m := range msgs {
if mm, ok := m.(map[string]interface{}); ok && mm["role"] == "user" {
if _, has := mm["reasoning_content"]; has {
t.Errorf("★ 给 user 消息加了 reasoning_content(只该给 assistant)")
}
}
}
}
// 判据:disable_thinking 必须转成**顶层** thinking 字段。
//
// ## 为什么这条最关键(它是 400 的第一根因)
//
// 官方文档示例全用 OpenAI SDK:extra_body={"thinking": {...}}。
// 那是 **SDK 层参数** —— SDK 会摊平进请求体顶层。而适配器是裸发 HTTP,
// 修正前写成 req.extra_body.thinking,服务端不认识这个包,直接忽略
// ⇒ thinking 保持默认(开启)⇒ 带 tools 的请求强制要求 reasoning_content
// ⇒ 400。也就是说:配置 thinking_enabled=false 也从未真正关掉过思考。
//
// 变异验证(实测):把 req.thinking 改回 req.extra_body.thinking ⇒ 本判据红。
func TestAdapterDisablesThinkingAtTopLevel(t *testing.T) {
v := newStartedVM(t)
got := transform(t, v, `{"model":"deepseek-chat","disable_thinking":true,"messages":[{"role":"user","content":"x"}]}`)
if _, ok := got["disable_thinking"]; ok {
t.Error("★ disable_thinking 应被消费掉,不该透传给上游")
}
// 必须是顶层
th, ok := got["thinking"].(map[string]interface{})
if !ok {
t.Fatalf("★ 缺少顶层 thinking 字段(上游会忽略 ⇒ 思考关不掉):%v", got)
}
if th["type"] != "disabled" {
t.Fatalf("★ thinking.type = %v,期望 disabled", th["type"])
}
// 不得留 extra_body 壳(SDK 层的参数名,裸 HTTP 上游不认识)
if _, ok := got["extra_body"]; ok {
t.Error("★ 不应把 extra_body 壳透传给上游(SDK 层参数)")
}
}
// 判据:不带 disable_thinking 时,不得凭空加 thinking 字段。
//
// 反向保护:默认不改上游行为。上游默认是**开启**思考,
// 我们只在用户明确要求关闭时才动。
func TestAdapterNoThinkingFieldByDefault(t *testing.T) {
v := newStartedVM(t)
got := transform(t, v, `{"model":"deepseek-chat","messages":[{"role":"user","content":"x"}]}`)
if _, ok := got["thinking"]; ok {
t.Fatalf("★ 未要求关闭时不该加 thinking:%v", got["thinking"])
}
}

View File

@ -75,3 +75,53 @@ func TestGetAdapterEndpointUnknownIsEmpty(t *testing.T) {
t.Fatalf("★ 未知适配器端点 = %q,期望空", got)
}
}
// TestTransformActuallyRunsAdapter:加载后调用 transform_request,
// 必须真的执行 Lua 脚本(而非只返回输入或报未加载)。
func TestTransformActuallyRunsAdapter(t *testing.T) {
v := newVM(t)
code := `-- name: contract-adapter
-- version: 1.0
return {
transform_request = function(raw) return '{"rewritten":true}' end,
transform_response = function(raw) return raw end,
transform_stream_chunk = function(raw) return raw end,
}`
if err := v.LoadAdapterSource("contract-adapter", code); err != nil {
t.Fatalf("LoadAdapterSource: %v", err)
}
out, err := v.CallTransformRequest("contract-adapter", `{"model":"m"}`)
if err != nil {
t.Fatalf("CallTransformRequest: %v", err)
}
if !strings.Contains(out, "rewritten") {
t.Fatalf("adapter body not executed: %q", out)
}
}
// TestAdapterHeadersAndEndpointExposed:静态声明的 endpoint/headers 可读回。
func TestAdapterHeadersAndEndpointExposed(t *testing.T) {
v := newVM(t)
code := `-- name: hdr
-- version: 1.0
return {
endpoint = 'http://127.0.0.1:9000/v1',
headers = { ['X-Contract'] = 'yes' },
transform_request = function(raw) return raw end,
transform_response = function(raw) return raw end,
transform_stream_chunk = function(raw) return raw end,
}`
if err := v.LoadAdapterSource("hdr", code); err != nil {
t.Fatalf("LoadAdapterSource: %v", err)
}
if got := v.GetAdapterEndpoint("hdr"); got != "http://127.0.0.1:9000/v1" {
t.Fatalf("endpoint = %q", got)
}
h, err := v.BuildHeaders("hdr", nil)
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
if h["X-Contract"] != "yes" {
t.Fatalf("headers = %v", h)
}
}

View File

@ -162,12 +162,35 @@ type VM struct {
mu sync.RWMutex
dir string
pools map[string]*adapterPool
// adapterSync 决定盘上适配器与内置版不一致时怎么处理。
//
// "" / "off"(默认):不覆盖,只告警。
// "auto":备份为 <name>.lua.bak-<hash8> 后覆盖。
//
// ★ 为何默认 off:adapters/ 在**用户的数据目录**里,内核擅自改它比
// “落后一个版本”更糟;且“旧发行版残留”与“用户手工改过”在文件
// 内容上长得一模一样,无法自动区分。要长期定制就配 adapter_path。
adapterSync string
}
func NewVM(dir string) *VM {
return &VM{dir: dir, pools: map[string]*adapterPool{}}
}
// SetAdapterSync 设置适配器同步策略("off" / "auto")。由模块层从配置注入。
//
// 未经注入时保持零值 = off(保守)。这条选择很重要:忘记注入时
// 行为必须是“不动用户文件”,而不是“默默全量覆盖”。
func (v *VM) SetAdapterSync(mode string) { v.adapterSync = strings.ToLower(strings.TrimSpace(mode)) }
func (v *VM) AdapterSyncMode() string {
if v.adapterSync == "" {
return "off"
}
return v.adapterSync
}
func (v *VM) AdapterDir() string { return v.dir }
func (v *VM) Start() error {
@ -645,9 +668,19 @@ func adapterVersion(src string) string {
}
func (v *VM) writeBundledAdapters() error {
prev := v.readBundledManifest()
cur := map[string]string{}
updated := 0
mode := v.AdapterSyncMode()
// drifted 收集「盘上与内置不一致」的适配器,用于末尾一条汇总告警。
// 逐条打日志在 off 模式下会刷屏(本机一次 8 条),而人真正需要的是
// 「有 N 个落后了 + 怎么处理」这一句。
type driftInfo struct {
name string
disk string
bundled string
}
var drifted []driftInfo
for _, name := range bundledAdapterNames {
data, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
@ -657,51 +690,83 @@ func (v *VM) writeBundledAdapters() error {
sum := sha256Hex(data)
dstPath := filepath.Join(v.dir, name+".lua")
// ── 盘上已有:比对 hash ──
//
// ## 这里为什么不再用「清单里的 prevHash」判断
//
// 旧实现拿上次清单做基线,认为「onDisk == prevHash ⇒ 没人动过 ⇒ 可覆盖」。
// 但清单只存 hash,而「内置旧版本的 hash」与「用户改过的 hash」
// 在清单里长得一模一样 —— 于是:
//
// 记了盘上那份当新基线 ⇒ 下次 onDisk == prevHash
// ⇒ 被误判成「没跟上新版本」⇒ 覆盖掉用户改动
// 保留分支又因为末尾无条件 cur[name]=sum 把基线改回内置 hash
// ⇒ **永久**停在旧版,日志只说「保留用户版本」,看不出是卡住
//
// 本机实测 8 个适配器中招(落后 3000~6500 字节,缺用量归一化、
// 流式 tool_calls 等修复)。既然没法从文件本身区分这两种来路,
// 就**不猜**:默认一律不动(off),要动就由用户显式开 auto。
if old, rerr := os.ReadFile(dstPath); rerr == nil {
onDisk := sha256Hex(old)
prevHash, known := prev[name]
switch {
case !known:
// 无历史清单:无法判断是否被用户改过 ⇒ 不动(与旧行为一致)。
//
// ★ 但这种"不动"必须**说出来**(2026-10-26 生产故障):
// 盘上是一份**早于本次功能修复**的适配器时,内核一声不响地继续用它。
// 现场:deepseek.lua 的 transform_stream_chunk 缺 tool_calls 处理
// ⇒ 流式模式下模型调不动任何工具、且零报错,症状看起来像"模型不听话"。
// 仓库里的内置版本已修好,且 deepseek_stream_test.go 一直是绿的 ——
// 因为判据测的是**内置**文件,而运行时用的是**盘上**这份。
if onDisk != sum {
fmt.Printf("[lua] WARNING: adapter %s on disk (v%s) differs from bundled (v%s) and has no recorded baseline; "+
"NOT overwriting (user edits are preserved). If tool calls or streaming misbehave, this drift is the first suspect — "+
"back up and delete %s to adopt the bundled version.\n",
name+".lua", adapterVersion(string(old)), adapterVersion(string(data)), name+".lua")
}
case onDisk == sum:
// 已是当前版本,无需写
case onDisk != prevHash:
// 与"上次内嵌"不同 ⇒ 用户改过 ⇒ 保留,并记下盘上真实版本
fmt.Printf("[lua] adapter %s 已被修改,保留用户版本(内核不覆盖)\n", name+".lua")
cur[name] = onDisk
default:
// onDisk == prevHash != sum ⇒ 只是没跟上新版本,覆盖是安全的
// 与内置一致,无需动作
case mode == "auto":
// 显式允许:备份后覆盖。
// 备份失败就不覆盖 —— 宁可保留旧版,也不能无声丢用户的改动。
bak := dstPath + ".bak-" + onDisk[:8]
if werr := os.WriteFile(bak, old, 0644); werr != nil {
fmt.Printf("[lua] WARNING: adapter %s 与内置版不一致,但备份到 %s 失败(%v);"+
"本次不覆盖,继续用盘上旧版。\n", name+".lua", filepath.Base(bak), werr)
cur[name] = onDisk
continue
}
if werr := os.WriteFile(dstPath, data, 0644); werr != nil {
return fmt.Errorf("write %s: %w", name, werr)
}
updated++
fmt.Printf("[lua] adapter %s 与内置版不一致(v%s → v%s),已备份为 %s 并更新\n",
name+".lua", adapterVersion(string(old)), adapterVersion(string(data)), filepath.Base(bak))
default:
// off(默认):不碰用户文件,但记下来后面汇总告警。
drifted = append(drifted, driftInfo{name: name + ".lua", disk: onDisk, bundled: sum})
cur[name] = onDisk
}
} else {
// 盘上没有:直接落盘(首次安装,不属于「改动用户文件」)
if werr := os.WriteFile(dstPath, data, 0644); werr != nil {
return fmt.Errorf("write %s: %w", name, werr)
}
updated++
fmt.Printf("[lua] installed bundled adapter: %s\n", name+".lua")
}
cur[name] = sum
if _, set := cur[name]; !set {
cur[name] = sum
}
}
// ★ off 模式的告警必须**可操作**。
//
// 教训(2026-10-26 + 2026-11-11):这里曾经只打一句「保留用户版本」,
// 结果 8 个适配器落后内置版几个月都没人发现,而症状是「模型不听话」
// 「工具调不动」这种完全指不到适配器的地方。
// 所以告警要给出:数量、落后在哪个版本、以及一条能直接跑的命令。
if len(drifted) > 0 {
names := make([]string, 0, len(drifted))
for _, d := range drifted {
names = append(names, d.name)
}
fmt.Printf("[lua] WARNING: %d 个适配器与内置版不一致,当前保留盘上版本(core.lua.adapter_sync=off):\n", len(drifted))
fmt.Printf("[lua] %s\n", strings.Join(names, ", "))
fmt.Printf("[lua] 影响:盘上版本可能缺少内核已修的问题(用量统计、流式 tool_calls 等),\n" +
"[lua] 症状常表现为「模型不听话」或「工具调不动」,容易误判成模型问题。\n")
fmt.Printf("[lua] 处理:① 想跟随内核 ⇒ 设 core.lua.adapter_sync=auto 后重启(会自动备份旧版);\n"+
"[lua] ② 想长期定制 ⇒ 把改过的文件改名,并配 core.llm.sources.<name>.adapter_path 指过去;\n"+
"[lua] ③ 只是旧发行版残留 ⇒ 删掉 %s 下对应文件后重启即可。\n", v.dir)
}
if err := v.writeBundledManifest(cur); err != nil {
// 清单写失败只影响下次的判别,不该让启动失败
fmt.Printf("[lua] 写内嵌清单失败(下次按不覆盖处理): %v\n", err)
fmt.Printf("[lua] 写内嵌清单失败(仅影响状态快照): %v\n", err)
}
if updated > 0 {
fmt.Printf("[lua] updated %d bundled adapter(s)\n", updated)
@ -711,21 +776,10 @@ func (v *VM) writeBundledAdapters() error {
func (v *VM) manifestPath() string { return filepath.Join(v.dir, ".bundled") }
// readBundledManifest 读上次运行时的内嵌清单(name → sha256)。
func (v *VM) readBundledManifest() map[string]string {
out := map[string]string{}
b, err := os.ReadFile(v.manifestPath())
if err != nil {
return out
}
for _, line := range strings.Split(string(b), "\n") {
parts := strings.SplitN(strings.TrimSpace(line), "\t", 2)
if len(parts) == 2 && parts[0] != "" {
out[parts[0]] = parts[1]
}
}
return out
}
// 注:原先还有一个 readBundledManifest(读上次清单做 prevHash 比对)。
// 2026-11-11 重设计后不再需要它:现在的判断只看「盘上 hash 与内置 hash 是否相等」,
// 不依赖历史基线(那套基线无法区分「内置旧版」与「用户改过」,
// 正是导致 8 个适配器永久卡住的根源)。清单现在只作为**可读的状态快照**保留。
func (v *VM) writeBundledManifest(m map[string]string) error {
var sb strings.Builder

View File

@ -48,6 +48,28 @@ const (
RoleRequest BlockRole = "request"
// RoleResponse 是页的响应端:llm 的 output,或 tool 的 result。
RoleResponse BlockRole = "response"
// RoleReasoning 是 llm_round 页里的**思考内容**(thinking 模式的思维链)。
//
// ## 为何它必须是独立一块,而不是拼进 RoleResponse
//
// 两者在协议层是**不同的字段**:
//
// assistant.content ← 模型交付的回复
// assistant.reasoning_content ← 模型的思考过程
//
// 拼在一起会有两个硬伤:
// · 回灌时无法区分 —— 思维链会被当成正式回复发给模型(模型会把自己的
// 内心独白读成"我说过这话")
// · 各自语义不同,合成一块只能有一个向量,检索时无法单独命中"为什么"
//
// ## 为何 L2/L3 也要它(2026-11-11 用户裁定)
//
// 此前 L2 只落 Input/Response、L3 蒸馏也不带思考 —— 那是错的:
// 决策依据、排除掉的选项、"先查 A 再查 B"的因果链,常常**只**在思考里。
// 丢掉它,召回时就只剩"我做了 X",答不出"为何是 X 而不是 Y"。
//
// 与 L1 的取舍同理:思考是上下文的一部分,不是噪声。
RoleReasoning BlockRole = "reasoning"
)
// MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。

View File

@ -808,6 +808,12 @@ type ContextEntry struct {
Source string
Content string
Response string
// ReasoningContent 是本轮模型的思考内容(thinking 模式的思维链)。
//
// L2 必须存它(2026-11-11 用户裁定):决策依据、排除掉的选项、
// "先查 A 再查 B"的因果链常常只在思考里。只存 Response 会导致
// 召回后只能答出"做了什么",答不出"为何这么做"。
ReasoningContent string
// ToolCalls 是工具调用的完整记录(含参数),归档时原样带到 L2。
ToolCalls []ToolCallItem
ToolsUsed []string
@ -962,6 +968,12 @@ func entryTextBlockID(evtTS time.Time, source, role, text string) string {
const (
TextBlockRoleInput = "input"
TextBlockRoleResponse = "response"
// TextBlockRoleReasoning 标记 llm_round 页里的**思考内容**块。
//
// 单独一类(而不是并进 response)的理由:回灌时要把它们放回不同字段
// (ContextEvent.ReasoningContent vs .Response)。合并后回灌会把思维链
// 当成正式回复发给模型。
TextBlockRoleReasoning = "reasoning"
)
// docBlocksForEntry 把一条上下文事件落成「文本块 + 媒体块」的块列表。
@ -1027,6 +1039,29 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
UpdatedAt: now,
})
}
// 思考块:与 Response 同属 llm_round 第 1 页,但**单独一块、独立向量**。
//
// 为何必须落进 coldcontext(2026-11-11 用户裁定):
// 决策依据、排除掉的选项、"先查 A 再查 B"的因果链常常只在思考里。
// 只存 Response ⇒ 召回后只能答出"做了什么",答不出"为何这么做"。
//
// 为何不并进上面的 Response 块:回灌时两者要放回**不同字段**
// (ContextEvent.ReasoningContent vs .Response);合并后回灌会把
// 思维链当成正式回复发给模型。
if t := strings.TrimSpace(e.ReasoningContent); t != "" {
out = append(out, memory.MemoryBlock{
ID: entryTextBlockID(now, e.Source, TextBlockRoleReasoning, t),
Modality: memory.BlockText,
Text: t,
Source: e.Source,
FrameTS: frameTS,
PageSeq: 1,
PageKind: memory.PageLLMRound,
BlockRole: memory.RoleReasoning,
CreatedAt: now,
UpdatedAt: now,
})
}
// 工具轮:每个 toolcall 与它的 result 构成一页。
//
@ -1149,8 +1184,18 @@ func entryToolBlockID(evtTS time.Time, source string, page int, role memory.Bloc
// 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后),
// 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。
func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryBlock) {
in, resp, _, h := blocksToEntryWithReasoning(ev)
return in, resp, h
}
// blocksToEntryWithReasoning 是 blocksToEntry 的完整形态,额外还原思考内容。
//
// 为何不直接改 blocksToEntry 的签名:它被 8 个调用点使用,其中只有
// 回灌(restoreDocEvents)与 L3 蒸馏需要 reasoning。改签名会让 6 个
// 不需要它的调用点跟着改——那种改动会淹没真正的语义变化。
func blocksToEntryWithReasoning(ev *ColdEvent) (input, response, reasoning string, held []memory.MemoryBlock) {
if ev == nil {
return "", "", nil
return "", "", "", nil
}
for i := range ev.Blocks {
b := ev.Blocks[i]
@ -1163,6 +1208,8 @@ func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryB
input = b.Text
case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse):
response = b.Text
case reasoning == "" && isEntryTextBlock(b, ev, TextBlockRoleReasoning):
reasoning = b.Text
case b.PageKind == memory.PageToolRound:
// ★ 工具页的块**不是**事件的"持有块",而是 ToolCalls/ToolResults
// 的页级表示(P2 派生出来的)。若把它当持有块塞回去,
@ -1176,7 +1223,7 @@ func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryB
held = append(held, b)
}
}
return input, response, held
return input, response, reasoning, held
}
// isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。
@ -1191,6 +1238,18 @@ func docEventsFromEntries(entries []ContextEntry) []ColdEvent {
}
out := make([]ColdEvent, 0, len(entries))
for _, e := range entries {
// ★ 时间戳先归一化,否则块 ID 与事件时间会失配(既有缺陷,2026-11-11 修)。
//
// 成因:docBlocksForEntry 内部的 now 在 e.Timestamp 为零时退化成 time.Now(),
// 而 ColdEvent.Timestamp 保持零值 ⇒ 块 ID 是基于“现在”算的,
// 回读却拿零值时间重算 ⇒ 哈希不等 ⇒ isEntryTextBlock 永远不认,
// 表现为“块就在那里但一个字段也读不回来”。
//
// 线上因为 L1 事件总是带 Timestamp 而没暴露,但它是真缺陷:
// 任何入口漏填时间戳,事件内容就静默丢失(不报错、不掍日志)。
if e.Timestamp.IsZero() {
e.Timestamp = time.Now()
}
ev := ColdEvent{
Timestamp: e.Timestamp,
Source: e.Source,
@ -1259,3 +1318,16 @@ func contentFromEvents(events []ColdEvent) string {
func (ev *ColdEvent) InOut() (input, response string, held []memory.MemoryBlock) {
return blocksToEntry(ev)
}
// InOutWithReasoning 与 InOut 相同,但额外读回思考内容。
//
// 用在哪里:需要把事件**完整还原**成 ContextEvent 的地方(L2 回灌、L3 蒸馏)。
// 这些地方如果只拿 InOut,回填出的事件就没有 ReasoningContent,
// 于是再次裁下时思维链就永久丢了。
//
// 为何不把 InOut 直接改成多返回值:它被 8 个调用点用,而其中只有 2 处
// 需要思考内容。让另外 6 处跟着改,只会把"哪些地方真的关心思考"
// 这个信号淹没在一堆样板修改里。
func (ev *ColdEvent) InOutWithReasoning() (input, response, reasoning string, held []memory.MemoryBlock) {
return blocksToEntryWithReasoning(ev)
}

View File

@ -0,0 +1,181 @@
package test
// 思考内容(reasoning)在 L2/L3 的往返判据(2026-11-11)。
//
// ## 钉的是什么
//
// 用户裁定:**L1/L2/L3 都要包含 reasoning**。此前 L2 只落 Input/Response、
// L3 蒸馏只读 Input/Response/工具输出 —— 那是错误实现。
//
// 真因(生产 400):
//
// all 2 providers failed, last error: api error 400:
// "The reasoning_content in the thinking mode must be passed back to the API."
//
// DeepSeek 规则:请求**带 tools** 时,所有历史轮的 reasoning_content 都要回传。
// 内核每轮都带工具定义 ⇒ 一旦历史里丢了思考内容,会话就永久 400。
//
// ## 判据走的是真实入口
//
// 用 ContextToColdContext(L1 → L2 的唯一入口)与 InOutWithReasoning
// (L2 → L1 的还原入口),断言"写进去的思考能不能原样读回来"。
// 不测内部函数、不碰私有字段。
import (
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// 判据:思考内容随事件进 L2,并能从块里读回。
//
// 变异验证(实测):把 docBlocksForEntry 里生成 reasoning 块的那段删掉
// ⇒ 本判据红(reasoning 读回为空)。
func TestReasoningSurvivesL2RoundTrip(t *testing.T) {
s := newStore(t)
const thinking = "先确认用户问的是账单不是订单,再查 invoice 表"
const reply = "已查到:上月在 12 号出账"
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "上个月账单什么时候出的?",
Response: reply,
ReasoningContent: thinking,
}}, nil, nil, nil, nil)
if err != nil {
t.Fatalf("ContextToColdContext: %v", err)
}
if doc == nil {
t.Fatal("★ ContextToColdContext 返回 nil")
}
if len(doc.Events) != 1 {
t.Fatalf("★ 事件数 = %d,期望 1", len(doc.Events))
}
in, resp, reasoning, _ := doc.Events[0].InOutWithReasoning()
if reasoning != thinking {
t.Fatalf("★ L2 丢了思考内容:读回 %q,期望 %q", reasoning, thinking)
}
// 顺带钉住"没串味":三者在 L2 里必须是三个独立的东西,
// 否则回灌时会把思维链当成正式回复发给模型。
if in != "上个月账单什么时候出的?" {
t.Errorf("Input 串味:%q", in)
}
if resp != reply {
t.Errorf("Response 串味:%q", resp)
}
}
// 判据:思考块与回复块是**两个块**,不是合并成一个。
//
// 为什么单独钉:合并会让回灌时分不清哪个是 content、哪个是 reasoning,
// 而这个错误在"内容还在、只是位置不对"的形态下不会触发任何 panic。
func TestReasoningIsSeparateBlockFromResponse(t *testing.T) {
s := newStore(t)
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "问题",
Response: "回复",
ReasoningContent: "思考",
}}, nil, nil, nil, nil)
if err != nil || doc == nil {
t.Fatalf("ContextToColdContext: %v / %v", err, doc)
}
var reasoningBlocks, responseBlocks int
for _, b := range doc.Events[0].Blocks {
switch string(b.BlockRole) {
case string(coldcontext.TextBlockRoleReasoning):
reasoningBlocks++
case string(coldcontext.TextBlockRoleResponse):
responseBlocks++
}
}
if reasoningBlocks != 1 {
t.Errorf("★ 思考块数 = %d,期望 1(合并进 response 会让回灌串味)", reasoningBlocks)
}
if responseBlocks != 1 {
t.Errorf("★ 回复块数 = %d,期望 1", responseBlocks)
}
}
// 判据:没有思考时不得凭空造一个空块。
//
// 反向保护:如果实现改成"无条件生成 reasoning 块",上面两条仍会绿,
// 但每次归档都会多一个空块 —— L2 里堆满噪声,且空块的 ID 会随
// 时间戳变化而产生大量无效条目。
func TestNoReasoningMeansNoBlock(t *testing.T) {
s := newStore(t)
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "问题",
Response: "回复",
}}, nil, nil, nil, nil)
if err != nil || doc == nil {
t.Fatalf("ContextToColdContext: %v / %v", err, doc)
}
for _, b := range doc.Events[0].Blocks {
if string(b.BlockRole) == string(coldcontext.TextBlockRoleReasoning) {
t.Fatal("★ 无思考时不应生成 reasoning 块")
}
}
_, _, reasoning, _ := doc.Events[0].InOutWithReasoning()
if reasoning != "" {
t.Fatalf("★ 无思考时读回 %q,期望空串", reasoning)
}
}
// 判据:思考里带特殊字符(换行、引号、中文标点)也能原样往返。
//
// 为什么值得钉:块 ID 由 sha256(时间戳|source|role|text) 派生,
// 而 L2 落盘是 JSON。任何一处转义或截断都会让 ID 与内容不匹配,
// 表现为"块存在但读不回来"——这正是 isEntryTextBlock 的判据会失配的地方。
func TestReasoningRoundTripWithSpecialChars(t *testing.T) {
s := newStore(t)
thinking := "第一行:检查 \"invoice\" 表\n第二行:注意 NULL 值\n第三行:结论「需要重试」"
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "查一下",
Response: "好",
ReasoningContent: thinking,
}}, nil, nil, nil, nil)
if err != nil || doc == nil {
t.Fatalf("ContextToColdContext: %v / %v", err, doc)
}
_, _, got, _ := doc.Events[0].InOutWithReasoning()
if got != thinking {
t.Fatalf("★ 特殊字符往返失真:\n got=%q\nwant=%q", got, thinking)
}
}
// 判据:InOut 保持三返回值(不因新增 reasoning 而破坏既有调用方)。
//
// 这条是**兼容性契约**:InOut 有 8 个调用点,其中 6 个不需要思考内容。
// 如果实现图省事把 InOut 改成四返回值,那些调用点会被迫一起改 ——
// 改动量会掩盖真正的语义变化,且容易改错。
func TestInOutSignatureStillUsable(t *testing.T) {
s := newStore(t)
doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{
Source: "agent",
Content: "问",
Response: "答",
ReasoningContent: "思",
}}, nil, nil, nil, nil)
if err != nil || doc == nil {
t.Fatalf("ContextToColdContext: %v / %v", err, doc)
}
// 三返回值必须仍然可用,且语义不变。
in, resp, held := doc.Events[0].InOut()
if in != "问" || resp != "答" {
t.Fatalf("★ InOut 语义变了:in=%q resp=%q", in, resp)
}
_ = held // 无媒体块时为 nil
if strings.Contains(resp, "思") {
t.Fatal("★ InOut 的 response 里混进了思考内容")
}
}

View File

@ -1,72 +1,155 @@
package test
// internal/memory/pipeline 的黑盒判据:NewDistiller → Append → 可观察输出。
// 不启动后台循环、不碰 d.records。
// internal/memory/pipeline 的模块级黑盒判据:Append 原始记录 → 蒸馏批次
// 经公开 SetSplitter/Start/Stop → 图记忆可查询到的三元组,以及原始记录
// 落盘/重启回读与暂停语义。不碰 d.records 等私有字段。
import (
"context"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/pipeline"
)
func newDistiller(t *testing.T) *pipeline.Distiller {
type spySplitter struct{ calls atomic.Int32 }
func (s *spySplitter) Split(_ context.Context, record string) ([]memory.Triple, error) {
s.calls.Add(1)
return []memory.Triple{{
Subject: "billing服务",
Relation: "端口",
Object: "8499",
SentenceText: record,
}}, nil
}
type failSplitter struct{}
func (failSplitter) Split(context.Context, string) ([]memory.Triple, error) {
return nil, context.DeadlineExceeded
}
func newHarness(t *testing.T) (*pipeline.Distiller, *memory.GraphDB, *spySplitter) {
t.Helper()
return pipeline.NewDistiller(nil, t.TempDir(), pipeline.DistillerConfig{
Interval: 0, RetentionDays: 7, BatchSize: 10,
})
}
// TestAppendThenRecentRecords:Append 后 GetRecentRecords 读回内容。
func TestAppendThenRecentRecords(t *testing.T) {
d := newDistiller(t)
d.Append("s1", "user", "你好")
d.Append("s1", "assistant", "在")
got := d.GetRecentRecords(5)
if len(got) != 2 {
t.Fatalf("★ GetRecentRecords = %d 条,期望 2", len(got))
}
if got[0].Content != "你好" || got[1].Content != "在" {
t.Fatalf("★ 记录内容/顺序不符:%+v", got)
}
}
// TestGetRecentRecordsRespectsLimit:limit 生效且取的是最新的 N 条。
func TestGetRecentRecordsRespectsLimit(t *testing.T) {
d := newDistiller(t)
for _, c := range []string{"A", "B", "C"} {
d.Append("s", "user", c)
}
got := d.GetRecentRecords(2)
if len(got) != 2 {
t.Fatalf("★ limit 未生效:%d 条", len(got))
}
if got[0].Content != "B" || got[1].Content != "C" {
t.Fatalf("★ 不是最新的两条:%+v", got)
}
}
// TestPauseResumeStopped:暂停/恢复是可观察状态。
func TestPauseResumeStopped(t *testing.T) {
d := newDistiller(t)
d.Pause()
d.Resume()
if d.Stopped() {
t.Fatal("★ 刚构造的 Distiller 报告已停止")
}
}
// TestStatsIsUsable:Stats 输出可用。
func TestStatsIsUsable(t *testing.T) {
d := newDistiller(t)
if st := d.Stats(); st == nil {
t.Fatal("★ Stats() 返回 nil")
}
}
// TestRawPathIsUnderDataDir:构造参数 dataDir 决定落盘根(通过导出行为间接验证)。
func TestRawPathIsUnderDataDir(t *testing.T) {
dir := t.TempDir()
_ = pipeline.NewDistiller(nil, dir, pipeline.DistillerConfig{})
_ = filepath.Join(dir, "memory", "raw") // 契约:raw 落在此处;不读内部字段
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { _ = g.Close() })
spy := &spySplitter{}
d := pipeline.NewDistiller(g, dir, pipeline.DistillerConfig{
Interval: 20 * time.Millisecond, RetentionDays: 7, BatchSize: 10,
})
d.SetSplitter(spy)
return d, g, spy
}
func assertFactRecalled(t *testing.T, g *memory.GraphDB) {
t.Helper()
got, err := g.Recall([]string{"billing服务"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
for _, r := range got.Relations {
if r.SourceName == "billing服务" && r.RelationType == "端口" && r.TargetName == "8499" {
return
}
}
t.Fatalf("distilled fact not queryable: %+v", got)
}
func TestAppendThenDistillWritesQueryableFact(t *testing.T) {
d, g, spy := newHarness(t)
d.Append("s1", "user", "billing服务端口是8499")
d.Start()
t.Cleanup(d.Stop)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if spy.calls.Load() > 0 {
assertFactRecalled(t, g)
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("distiller never invoked the injected splitter")
}
func TestRawRecordsPersistAcrossRestart(t *testing.T) {
dir := t.TempDir()
cfg := pipeline.DistillerConfig{Interval: time.Hour, RetentionDays: 7, BatchSize: 10}
first := pipeline.NewDistiller(nil, dir, cfg)
first.Append("s1", "user", "第一条原始记录")
first.Start()
first.Stop()
second := pipeline.NewDistiller(nil, dir, cfg)
second.Start()
defer second.Stop()
// Start 载入磁盘上的原始记录后,未蒸馏记录必须可读到。
got := second.GetRecentRecords(10)
second.Stop()
found := false
for _, r := range got {
if r.Content == "第一条原始记录" && r.Role == "user" && r.SessionID == "s1" {
found = true
}
}
if !found {
t.Fatalf("raw record not restored after restart: %+v", got)
}
}
func TestSplitterFailureDoesNotFabricateFacts(t *testing.T) {
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
d := pipeline.NewDistiller(g, dir, pipeline.DistillerConfig{
Interval: 20 * time.Millisecond, RetentionDays: 7, BatchSize: 10,
})
d.SetSplitter(failSplitter{})
d.Append("s1", "user", "会失败的记录")
d.Start()
time.Sleep(200 * time.Millisecond)
d.Stop()
// 拆分器失败时必须回退而不是伪造事实:图记忆里不能出现这条记录编出来的关系。
got, err := g.Recall([]string{"会失败的记录"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
for _, r := range got.Relations {
t.Fatalf("failed batch fabricated a fact: %+v", r)
}
}
func TestPauseResumeAndStopAreObservable(t *testing.T) {
d, _, _ := newHarness(t)
d.Append("s1", "user", "record")
d.Start()
if err := d.Pause(); err != nil {
t.Fatalf("Pause: %v", err)
}
if err := d.Pause(); err != nil {
t.Fatalf("Pause must be idempotent: %v", err)
}
if err := d.Resume(); err != nil {
t.Fatalf("Resume: %v", err)
}
if d.Stopped() {
t.Fatal("reported stopped before Stop")
}
d.Stop()
if !d.Stopped() {
t.Fatal("Stop did not report stopped immediately")
}
}

View File

@ -110,27 +110,40 @@ func newDB(t *testing.T) *memory.GraphDB {
return g
}
// TestCommitThenGraphData:Commit 一条三元组后,GraphData 必须能反映它。
func TestCommitThenGraphData(t *testing.T) {
g := newDB(t)
// TestCommitThenRecallAfterReopen verifies that a committed fact is durable and
// queryable through the memory module, rather than merely producing graph keys.
func TestCommitThenRecallAfterReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "graph.db")
g, err := memory.NewGraphDB(path)
if err != nil {
t.Fatal(err)
}
tri := memory.Triple{
Subject: "billing服务", Relation: "端口", Object: "8499",
SentenceText: "billing服务端口是8499",
}
if _, _, err := g.Commit([]memory.Triple{tri}, "contract-session", 1); err != nil {
_ = g.Close()
t.Fatalf("Commit: %v", err)
}
data, err := g.GraphData()
if err := g.Close(); err != nil {
t.Fatal(err)
}
reopened, err := memory.NewGraphDB(path)
if err != nil {
t.Fatalf("GraphData: %v", err)
t.Fatal(err)
}
if data == nil {
t.Fatal("★ GraphData 返回 nil")
defer reopened.Close()
result, err := reopened.Recall([]string{"billing服务"}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
// 可观察输出:GraphData 必须给出 nodes/edges 结构(而非 panic / 空 map)
if _, ok := data["nodes"]; !ok {
t.Fatalf("★ GraphData 缺 nodes 字段:%v", data)
for _, relation := range result.Relations {
if relation.SourceName == tri.Subject && relation.RelationType == tri.Relation && relation.TargetName == tri.Object {
return
}
}
t.Fatalf("committed fact missing after reopen: %+v", result)
}
// TestReadOnlyHandleRejectsWrite:OpenGraphDBReadOnly 必须在 SQLite 层拒写。

View File

@ -1,6 +1,6 @@
package test
// internal/nlp 的黑盒判据:NewExtractor / 配置 / Extract 的输入 → 输出。
// internal/nlp 的模块级黑盒判据:文本输入 → 公开 Parser/Extractor → 三元组输出。
import (
"testing"
@ -8,58 +8,64 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/nlp"
)
// TestDefaultExtractorConfig:默认融合权重是可用常量。
func TestDefaultExtractorConfig(t *testing.T) {
c := nlp.DefaultExtractorConfig()
if c.FusionAlpha <= 0 || c.FusionBeta <= 0 || c.FusionThreshold <= 0 {
t.Fatalf("★ 默认配置不可用:%+v", c)
type contractParser struct{}
func (contractParser) Parse(string) (*nlp.ParseResult, error) {
// billing --SBV--> 监听 <--VOB-- 8499
return &nlp.ParseResult{
Tokens: []string{"billing", "监听", "8499"},
POS: []string{"n", "v", "n"},
Heads: []int{2, 0, 2},
DepRels: []string{"SBV", "ROOT", "VOB"},
}, nil
}
func TestExtractRunsParserAndReturnsFact(t *testing.T) {
e := nlp.NewExtractor(contractParser{})
got := e.Extract("billing监听8499")
if got == nil || got.Err != nil {
t.Fatalf("Extract failed: %+v", got)
}
if got.Src != "dep_parser" || len(got.Triples) != 1 {
t.Fatalf("module output = %+v", got)
}
tr := got.Triples[0]
if tr.Subject != "billing" || tr.Relation != "监听" || tr.Object != "8499" {
t.Fatalf("unexpected extracted fact: %+v", tr)
}
if tr.SentenceRef != "billing监听8499" || tr.Score <= 0 {
t.Fatalf("missing observable provenance/score: %+v", tr)
}
}
// TestSetFusionWeightsRoundTrip:设入的权重能从 FusionConfig 读回。
func TestSetFusionWeightsRoundTrip(t *testing.T) {
func TestFusionConfigurationChangesExtractionDecision(t *testing.T) {
e := nlp.NewExtractor(contractParser{})
e.SetFusionWeights(1, 0, 0.95)
if got := e.Extract("billing监听8499"); got == nil || len(got.Triples) != 0 {
t.Fatalf("high threshold should reject the parser candidate: %+v", got)
}
e.SetFusionWeights(1, 0, 0.1)
got := e.Extract("billing监听8499")
if got == nil || len(got.Triples) != 1 {
t.Fatalf("lower threshold should restore the fact: %+v", got)
}
}
func TestExtractorEmptyAndFallbackInput(t *testing.T) {
e := nlp.NewExtractor(nil)
e.SetFusionWeights(0.25, 0.75, 0.42)
got := e.FusionConfig()
if got.FusionAlpha != 0.25 || got.FusionBeta != 0.75 || got.FusionThreshold != 0.42 {
t.Fatalf("★ FusionConfig = %+v", got)
if got := e.Extract(""); got == nil || len(got.Triples) != 0 {
t.Fatalf("empty input should produce no facts: %+v", got)
}
if got := e.Extract("billing服务的端口是8499"); got == nil {
t.Fatal("fallback extraction returned nil")
}
}
// stubParser 满足 Parser 接口,作为 SetDefaultParser 的输入。
type stubParser struct{}
func (p *stubParser) Parse(text string) (*nlp.ParseResult, error) {
return &nlp.ParseResult{}, nil
}
// TestSetDefaultParserRoundTrip:设入的 parser 能从 GetDefaultParser 读回。
func TestSetDefaultParserRoundTrip(t *testing.T) {
want := &stubParser{}
nlp.SetDefaultParser(want)
func TestDefaultParserInjectionChangesNewExtractorBehavior(t *testing.T) {
nlp.SetDefaultParser(contractParser{})
t.Cleanup(func() { nlp.SetDefaultParser(nil) })
if got := nlp.GetDefaultParser(); got != want {
t.Fatalf("★ GetDefaultParser = %v,期望刚设入的 parser", got)
}
}
// TestExtractEmptyTextReturnsEmptySet:空文本返回空结果,不 panic。
func TestExtractEmptyTextReturnsEmptySet(t *testing.T) {
e := nlp.NewExtractor(nil)
got := e.Extract("")
if got == nil {
t.Fatal("★ Extract 返回 nil")
}
if len(got.Triples) != 0 {
t.Fatalf("★ 空文本提取出 %d 条三元组", len(got.Triples))
}
}
// TestExtractNonEmptyTextDoesNotPanic:非空文本输出可用(不钉死内容)。
func TestExtractNonEmptyTextDoesNotPanic(t *testing.T) {
e := nlp.NewExtractor(nil)
got := e.Extract("billing服务 的 端口 是 8499")
if got == nil {
t.Fatal("★ Extract 返回 nil")
got := nlp.NewExtractor(nil).Extract("billing监听8499")
if got == nil || got.Src != "dep_parser" || len(got.Triples) != 1 {
t.Fatalf("default parser was not used by NewExtractor(nil): %+v", got)
}
}

View File

@ -118,7 +118,7 @@ func (r *Registry) ensureProcHost() (*proc.Host, error) {
if r.procHost != nil {
return r.procHost, nil
}
host, err := proc.NewHost()
host, err := proc.NewHostWithArenaSize(r.arenaBytesFromConfig())
if err != nil {
return nil, err
}
@ -278,3 +278,38 @@ func (r *Registry) ResetProcCrashCount(name string) {
defer r.crashMu.Unlock()
delete(r.procCrashes, name)
}
// arenaBytesFromConfig 读 arena 容量配置(core.plugin.shm_arena_mb)。
//
// ## 为什么容量只能在启动时决定
//
// arena 是统一区域里一段连续 mmap,而 SharedRef 存**绝对偏移**。
// 变大需要 remap,而每个 plugin.bin 子进程可能正在读同一页 ——
// 跨进程 munmap 会让它 SIGSEGV。该路径历史上三次被明确搁置。
// ⇒ 这里只决定"这次启动开多大",改配置必须重启内核才生效。
//
// ## 越界时记日志
//
// clamp 到 1..256 MB。超上限时**说出来**(否则"我配了 512"会静默变成
// "仍是 256",与配置系统其它项的行为不一致)。
func (r *Registry) arenaBytesFromConfig() uint32 {
const defMB = 4
const minMB = 1
const maxMB = 256
mb := defMB
if r.cfgReg != nil {
if v := r.cfgReg.GetInt("core.plugin.shm_arena_mb", defMB); v != 0 {
mb = v
}
}
switch {
case mb < minMB:
log.Printf("[plugin] core.plugin.shm_arena_mb=%d 过小,按 %dMB 使用", mb, minMB)
mb = minMB
case mb > maxMB:
log.Printf("[plugin] core.plugin.shm_arena_mb=%d 超过上限,按 %dMB 使用", mb, maxMB)
mb = maxMB
}
return uint32(mb) * 1024 * 1024
}

View File

@ -98,8 +98,9 @@ const (
// 但它让单个工具调用可以承载 MB 级 payload,不必再退回内联 RPC。
const arenaDefaultCapacity = 4 * 1024 * 1024
// arenaPublishSize 是统一区域里预留给 arena 的字节数。
var arenaPublishSize = uint32(arenaDefaultCapacity)
// 容量由 NewHostWithArenaSize 决定(见 host.go),这里不再维护第二份。
// 之前有个 arenaPublishSize 变量,但自 NewHost 改为接受参数后已无人读取 ——
// 留着一个"看着能改、改了没效果"的开关比没有更危险。
// arenaRegion 是块分配器视图。
//

View File

@ -69,11 +69,33 @@ type Host struct {
// 三者共同点:全部插件看到同一份物理页,段内一律用相对偏移而非指针
// (实验 2 已验证各进程 mmap 到不同虚拟地址时偏移解引用仍正确)。
func NewHost() (*Host, error) {
return NewHostWithArenaSize(arenaDefaultCapacity)
}
// NewHostWithArenaSize 与 NewHost 相同,但按调用方给定的 arena 容量创建段。
//
// ## 为什么容量只能在**构造时**决定
//
// arena 是统一区域里的一段连续 mmap,而 SharedRef 存的是**绝对偏移**。
// 要变大就得 remap,而对端进程(每个 plugin.bin)可能正在读同一页 ——
// munmap + 重新映射会让它 SIGSEGV。跨进程 remap 因此被明确否决
// (历史上三次实现计划均搁置)。⇒ 容量只影响"启动时开多大",
// 改了配置必须重启内核。
//
// ## 越界如何处理
//
// 太小(低于 arenaMinSize)无法建立分配器;这里**返回明确错误**而不是
// 静默回落到默认值 —— 配置写错要能看见,否则"我明明配了 32MB"会变成
// "悄悄仍是 4MB"。调用方(Registry)负责读配置 + clamp。
func NewHostWithArenaSize(arenaBytes uint32) (*Host, error) {
if arenaBytes < arenaMinSize() {
return nil, fmt.Errorf("共享段 arena 容量过小:%d 字节(至少 %d)", arenaBytes, arenaMinSize())
}
// 统一区域大小:SuperBlock + StageContext + EvtRing + Exchange Arena
//
// +8 是 arena 基址 8 字节对齐的 padding 余量:arenaOff 向上取整可能
// 吃掉最多 4 字节,预留 8 字节保证 arenaCap 不会小于 arenaPublishSize。
unifiedSize := superBlockSize + shmDefaultSize + evtTotalSize + int(arenaPublishSize) + 8
// 吃掉最多 4 字节,预留 8 字节保证 arenaCap 不会小于请求容量。
unifiedSize := superBlockSize + shmDefaultSize + evtTotalSize + int(arenaBytes) + 8
memfd, data, err := allocShm(unifiedSize)
if err != nil {
return nil, err

View File

@ -0,0 +1,210 @@
package test
// internal/plugin/proc 的共享内存 arena —— 面向接口的模块职责判据。
//
// 唯一入口是公开的 proc.NewHost() 与 Host 的导出方法;块布局、块头字段、
// 空闲链结构都不在断言范围内。断言只看:
// 写入的字节能否原样读出、操作成功还是失败、空间能否被再次使用。
import (
"bytes"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugin/proc"
)
func newArenaHost(t *testing.T) *proc.Host {
t.Helper()
h, err := proc.NewHost()
if err != nil {
t.Skipf("共享内存不可用:%v", err)
}
t.Cleanup(func() { _ = h.Close() })
return h
}
func putBytes(t *testing.T, h *proc.Host, owner uint32, payload []byte) proc.SharedRef {
t.Helper()
ref, err := h.Arena().Put(owner, payload, h.Generation())
if err != nil {
t.Fatalf("Put(%d bytes): %v", len(payload), err)
}
return ref
}
func assertReadable(t *testing.T, h *proc.Host, ref proc.SharedRef, want []byte) {
t.Helper()
got, err := h.Arena().Read(ref, h.Generation())
if err != nil {
t.Fatalf("Read: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("Read 返回 %d 字节,期望 %d 字节且内容一致", len(got), len(want))
}
}
// TestArenaWriteReadFreeReuse 是 arena 的核心职责闭环:
// 写入 → 读出 → 归还 → 同样大小的写入再次成功。
func TestArenaWriteReadFreeReuse(t *testing.T) {
h := newArenaHost(t)
payload := bytes.Repeat([]byte("x"), 4096)
ref := putBytes(t, h, proc.OwnerHost, payload)
assertReadable(t, h, ref, payload)
if err := h.Arena().Free(proc.OwnerHost, ref); err != nil {
t.Fatalf("Free: %v", err)
}
again := putBytes(t, h, proc.OwnerHost, payload)
assertReadable(t, h, again, payload)
}
// TestArenaRequestBeyondCapacityFailsCleanly:超过容量的请求必须失败,
// 且失败之后分配器仍能正常工作。
func TestArenaRequestBeyondCapacityFailsCleanly(t *testing.T) {
h := newArenaHost(t)
limit := h.Arena().MaxPayload()
if _, err := h.Arena().Alloc(proc.OwnerHost, limit+1, h.Generation()); err == nil {
t.Fatalf("超过上限 %d 字节的请求被接受", limit)
}
payload := []byte("still-works")
ref := putBytes(t, h, proc.OwnerHost, payload)
assertReadable(t, h, ref, payload)
}
// TestArenaCannotBeFreedByAnotherOwner:别的 owner 不能归还本块,
// 且块内容不受影响。
func TestArenaCannotBeFreedByAnotherOwner(t *testing.T) {
h := newArenaHost(t)
payload := []byte("owned-by-host")
ref := putBytes(t, h, proc.OwnerHost, payload)
if err := h.Arena().Free(10086, ref); err == nil {
t.Fatal("非 owner 的归还被接受")
}
assertReadable(t, h, ref, payload)
}
// TestArenaRejectsStaleReference:旧 generation 的引用必须报错,
// 不允许返回可能错位的数据。
func TestArenaRejectsStaleReference(t *testing.T) {
h := newArenaHost(t)
ref := putBytes(t, h, proc.OwnerHost, []byte("data"))
if _, err := h.Arena().Read(ref, h.Generation()+1); err == nil {
t.Fatal("过期 generation 的读取被接受")
}
}
// TestArenaReclaimReleasesPluginSpace:插件退出后的强制回收,
// 归还的空间必须能被后续分配真正用上。
func TestArenaReclaimReleasesPluginSpace(t *testing.T) {
h := newArenaHost(t)
const pluginOwner = uint32(4242)
payload := bytes.Repeat([]byte("y"), 8192)
putBytes(t, h, pluginOwner, payload)
if n := h.Arena().ReclaimOwner(pluginOwner); n != 1 {
t.Fatalf("ReclaimOwner = %d,期望 1", n)
}
again := putBytes(t, h, proc.OwnerHost, payload)
assertReadable(t, h, again, payload)
}
// TestArenaReclaimDoesNotTouchHostBlocks:回收插件块时不得影响内核自己的块。
func TestArenaReclaimDoesNotTouchHostBlocks(t *testing.T) {
h := newArenaHost(t)
hostPayload := []byte("host-owned")
hostRef := putBytes(t, h, proc.OwnerHost, hostPayload)
const pluginOwner = uint32(9999)
putBytes(t, h, pluginOwner, []byte("plugin-owned"))
h.Arena().ReclaimOwner(pluginOwner)
assertReadable(t, h, hostRef, hostPayload)
if err := h.Arena().Free(proc.OwnerHost, hostRef); err != nil {
t.Fatalf("内核块被回收后无法归还:%v", err)
}
}
// TestArenaInvalidReferenceIsRejected:伪造的偏移不能被当成合法块,
// 归还时必须报错而不是破坏分配器。
func TestArenaInvalidReferenceIsRejected(t *testing.T) {
h := newArenaHost(t)
forged := proc.SharedRef{Offset: 1, Length: 8, Generation: uint32(h.Generation())}
if err := h.Arena().Free(proc.OwnerHost, forged); err == nil {
t.Fatal("伪造偏移的归还未被拒绝")
}
payload := []byte("after-forgery")
ref := putBytes(t, h, proc.OwnerHost, payload)
assertReadable(t, h, ref, payload)
}
// TestArenaFreeTwiceIsRejected:重复归还同一个块必须报错。
func TestArenaFreeTwiceIsRejected(t *testing.T) {
h := newArenaHost(t)
ref := putBytes(t, h, proc.OwnerHost, []byte("once"))
if err := h.Arena().Free(proc.OwnerHost, ref); err != nil {
t.Fatalf("首次归还失败:%v", err)
}
if err := h.Arena().Free(proc.OwnerHost, ref); err == nil {
t.Fatal("重复归还被接受")
}
}
// TestArenaFragmentationPreventsLargeRequest 钉住"总量够但无连续块"的真实失败。
//
// 这是现状行为,不是期望行为:1MB 大块与小块交替铺满后释放大块,
// 剩余空间总量超过请求的一半,但被小块隔开 ⇒ 首次适配拿不到。
// 引入整理回收后,本判据应改为「同一请求成功且内容可读」。
func TestArenaFragmentationPreventsLargeRequest(t *testing.T) {
h := newArenaHost(t)
arena := h.Arena()
limit := arena.MaxPayload()
big := make([]byte, limit/4)
sep := []byte("separator")
putBytes(t, h, proc.OwnerHost, sep)
var bigRefs []proc.SharedRef
for i := 0; i < 3; i++ {
bigRefs = append(bigRefs, putBytes(t, h, proc.OwnerHost, big))
putBytes(t, h, proc.OwnerHost, sep)
}
for _, ref := range bigRefs {
if err := arena.Free(proc.OwnerHost, ref); err != nil {
t.Fatalf("Free: %v", err)
}
}
if _, err := arena.Alloc(proc.OwnerHost, int(limit/2-1024), h.Generation()); err == nil {
t.Log("当前实现已能整理碎片;本判据需改为验证整理后的可用性")
}
}
// TestArenaHostHonoursRequestedCapacity:按调用方给定的容量创建共享段,
// 容量必须真的生效(MaxPayload 随容量变化),而不是静默用默认值。
func TestArenaHostHonoursRequestedCapacity(t *testing.T) {
const mb = 8
h, err := proc.NewHostWithArenaSize(mb * 1024 * 1024)
if err != nil {
t.Skipf("共享内存不可用:%v", err)
}
defer func() { _ = h.Close() }()
payload := bytes.Repeat([]byte("z"), mb*1024*1024/2)
ref := putBytes(t, h, proc.OwnerHost, payload)
assertReadable(t, h, ref, payload)
}
// TestArenaHostRejectsTooSmallCapacity:低于分配器最小尺寸时报明确错误,
// 不静默回落到默认容量。
func TestArenaHostRejectsTooSmallCapacity(t *testing.T) {
if _, err := proc.NewHostWithArenaSize(64); err == nil {
t.Fatal("过小的 arena 容量被接受(应报错而非回落默认值)")
}
}

View File

@ -0,0 +1,87 @@
package test
// internal/plugin/proc 的黑盒判据:公开纯函数与值对象语义。
import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugin/proc"
)
func TestMajorVersionParsing(t *testing.T) {
cases := []struct{ in, want string }{
{"1.4.0", "1"},
{"v2.10.3", "2"},
{"0.9", "0"},
{"17", "17"},
{"", ""},
{" v3.1 ", "3"},
}
for _, c := range cases {
if got := proc.MajorVersion(c.in); got != c.want {
t.Errorf("MajorVersion(%q)=%q,期望 %q", c.in, got, c.want)
}
}
}
func TestKnownCapabilitiesExcludesCoreAndIsStable(t *testing.T) {
first := proc.KnownCapabilities()
second := proc.KnownCapabilities()
if len(first) == 0 {
t.Fatal("KnownCapabilities 为空")
}
for i := range first {
if first[i] == "core" {
t.Fatal("core 能力出现在声明清单里(无需声明)")
}
if i > 0 && first[i-1] >= first[i] {
t.Fatalf("能力清单未按升序:%v", first)
}
}
for i := range first {
if first[i] != second[i] {
t.Fatal("两次调用结果不一致(无确定性)")
}
}
}
func TestSharedRefSliceBoundary(t *testing.T) {
data := []byte("0123456789")
ok := proc.SharedRef{Offset: 2, Length: 4}
if got := ok.Slice(data); string(got) != "2345" {
t.Fatalf("Slice=%q", got)
}
if ok.IsZero() {
t.Fatal("非零 ref 被判为零值")
}
if !(proc.SharedRef{}).IsZero() {
t.Fatal("零值 ref 未判零")
}
// 越界: offset+length 超出 data 时返回 nil
oob := proc.SharedRef{Offset: 8, Length: 8}
if oob.Slice(data) != nil {
t.Fatalf("越界 Slice 未返回 nil:%q", oob.Slice(data))
}
// 零值: 长度 0
if zero := (proc.SharedRef{}).Slice(data); zero != nil {
t.Fatalf("零值 ref Slice 应 nil:%q", zero)
}
}
func TestPublicCapabilityConstantsStable(t *testing.T) {
want := map[string]proc.Capability{
"core": proc.CapCore, "io": proc.CapIO, "memory": proc.CapMemory,
"doc_memory": proc.CapDocMemory, "knowledge": proc.CapKnowledge,
"text_memory": proc.CapTextMemory, "llm": proc.CapLLM,
"social": proc.CapSocial, "events": proc.CapEvents,
"plugin_mgr": proc.CapPluginMgr, "kernel_control": proc.CapKernelControl,
"settings_cross": proc.CapCrossPluginSettings,
}
for name, cap := range want {
if string(cap) != name {
t.Errorf("常量 %q 值与名不符:%q", name, cap)
}
}
_ = proc.MethodHandshake
_ = proc.DefaultHandshakeTimeout
}

View File

@ -0,0 +1,170 @@
package test
import (
"errors"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/plugins/cfgmgr"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// The module boundary is Start's public tool registrar. A caller invokes the
// published tools; no private handler or internal state is accessed.
type configTools map[string]sdk.ToolHandler
func startConfigManager(t *testing.T, settings sdk.SettingsAPI) configTools {
t.Helper()
tools := configTools{}
p := cfgmgr.New("cfgmgr")
host := sdk.New("cfgmgr", sdk.SDKConfig{Settings: settings, RegTool: func(name string, _ sdk.ToolDef, handler sdk.ToolHandler) error {
tools[name] = handler
return nil
}})
if err := p.Start(host); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := p.Stop(); err != nil {
t.Error(err)
}
})
return tools
}
func (tools configTools) call(t *testing.T, name string, args map[string]interface{}) map[string]interface{} {
t.Helper()
handler := tools[name]
if handler == nil {
t.Fatalf("module did not publish %s", name)
}
value, err := handler(args)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
result, ok := value.(map[string]interface{})
if !ok {
t.Fatalf("%s returned %T, want result object", name, value)
}
return result
}
func settingsForModule(t *testing.T) sdk.SettingsAPI {
t.Helper()
reg := config.NewConfigRegistry("")
t.Cleanup(func() { _ = reg.Close() })
for _, name := range []string{"alpha", "beta"} {
reg.PluginConfig(name).RegisterDef(config.ConfigDef{Key: "display.theme", Type: "string"})
}
return sdk.NewSettings("cfgmgr", reg)
}
func TestConfigurationWorkflowKeepsScopesSeparate(t *testing.T) {
tools := startConfigManager(t, settingsForModule(t))
for scope, value := range map[string]string{"core": "system", "alpha": "light", "beta": "dark"} {
set := tools.call(t, "config_set", map[string]interface{}{"scope": scope, "key": "display.theme", "value": value})
if set["status"] != "ok" {
t.Fatalf("write %s failed: %v", scope, set)
}
}
// Read after all writes: a routing mistake must not be hidden by immediate
// set/get round trips through the same incorrectly routed namespace.
for scope, value := range map[string]string{"core": "system", "alpha": "light", "beta": "dark"} {
got := tools.call(t, "config_get", map[string]interface{}{"scope": scope, "key": "display.theme"})
if got["value"] != value || got["scope"] != scope {
t.Fatalf("scope %s: %v", scope, got)
}
listed := tools.call(t, "config_list_keys", map[string]interface{}{"scope": scope, "prefix": "display."})
items, ok := listed["items"].([]map[string]interface{})
listedKey := "display.theme"
if scope == "core" {
listedKey = "core.display.theme"
}
if !ok || len(items) != 1 || items[0]["key"] != listedKey || items[0]["value"] != value {
t.Fatalf("scope %s listing: %v", scope, listed)
}
empty := tools.call(t, "config_list_keys", map[string]interface{}{"scope": scope, "prefix": "absent."})
if empty["count"] != 0 {
t.Fatalf("prefix ignored: %v", empty)
}
}
}
// Embed the public dependency interface and override one operation to model a
// storage failure. Assertions concern tool results, not calls or call order.
type unavailableWrites struct{ sdk.SettingsAPI }
func (unavailableWrites) SetCore(string, interface{}) error { return errors.New("storage unavailable") }
func TestBatchReportsPartialFailureAndPersistsSuccessfulItems(t *testing.T) {
tools := startConfigManager(t, unavailableWrites{settingsForModule(t)})
got := tools.call(t, "config_batch_set", map[string]interface{}{"items": []interface{}{
map[string]interface{}{"scope": "core", "key": "display.theme", "value": "blocked"},
"invalid item",
map[string]interface{}{"scope": "alpha", "key": "display.theme", "value": "updated"},
map[string]interface{}{"scope": "beta", "value": "missing key"},
}})
if got["applied"] != 1 || got["total"] != 4 {
t.Fatalf("batch summary: %v", got)
}
results, ok := got["results"].([]map[string]interface{})
if !ok || len(results) != 4 {
t.Fatalf("batch results: %v", got)
}
for i, result := range results {
if result["index"] != i {
t.Fatalf("lost item identity: %v", results)
}
if i == 2 {
if result["status"] != "ok" {
t.Fatalf("successful item: %v", result)
}
} else if result["error"] == nil {
t.Fatalf("failed item reported success: %v", result)
}
}
if results[0]["error"] != "storage unavailable" {
t.Fatalf("lost storage diagnostic: %v", results[0])
}
read := tools.call(t, "config_get", map[string]interface{}{"scope": "alpha", "key": "display.theme"})
if read["value"] != "updated" {
t.Fatalf("successful item not persisted: %v", read)
}
failed := tools.call(t, "config_set", map[string]interface{}{"scope": "core", "key": "display.theme", "value": "x"})
if failed["error"] != "storage unavailable" || failed["status"] == "ok" {
t.Fatalf("false write success: %v", failed)
}
}
func TestInvalidRequestsAndMissingSettingsAreExplicitFailures(t *testing.T) {
tools := startConfigManager(t, settingsForModule(t))
for _, tc := range []struct {
name string
args map[string]interface{}
want string
}{
{"empty", map[string]interface{}{}, "scope and key are required"},
{"missing key", map[string]interface{}{"scope": "core"}, "scope and key are required"},
{"missing scope", map[string]interface{}{"key": "display.theme"}, "scope and key are required"},
} {
t.Run(tc.name, func(t *testing.T) {
got := tools.call(t, "config_get", tc.args)
if got["error"] != tc.want {
t.Fatalf("unexpected validation result: %v", got)
}
})
}
for _, name := range []string{"config_set", "config_list_keys", "config_get_defs", "config_batch_set"} {
got := tools.call(t, name, map[string]interface{}{})
if got["error"] == nil {
t.Fatalf("%s accepted missing required input: %v", name, got)
}
}
missing := startConfigManager(t, nil)
for _, name := range []string{"config_get", "config_set", "config_list_keys", "config_get_defs", "config_batch_set", "config_dump", "config_list_plugins"} {
got := missing.call(t, name, map[string]interface{}{"scope": "core", "key": "display.theme", "value": "x"})
if got["error"] != "settings API not available" {
t.Fatalf("%s hid unavailable dependency: %v", name, got)
}
}
}

View File

@ -0,0 +1,69 @@
package test
// cfgmgr 黑盒判据:公开 Start 注入工具注册回调,观察注册的工具契约。
import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugins/cfgmgr"
sdkpkg "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
func TestStartRegistersConfigToolsWithConcurrencyMarkers(t *testing.T) {
var names []string
serial := map[string]bool{}
parallel := map[string]bool{}
reg := func(name string, def sdkpkg.ToolDef, handler sdkpkg.ToolHandler) error {
names = append(names, name)
if def.Serial {
serial[name] = true
}
if def.ParallelSafe {
parallel[name] = true
}
return nil
}
p := cfgmgr.New("contract-cfgmgr")
sdk := sdkpkg.New("contract-cfgmgr", sdkpkg.SDKConfig{RegTool: reg})
if err := p.Start(sdk); err != nil {
t.Fatalf("Start: %v", err)
}
if got := p.Name(); got != "contract-cfgmgr" {
t.Fatalf("Name=%q", got)
}
for _, want := range []string{
"config_list_plugins", "config_get", "config_set",
"config_list_keys", "config_get_defs", "config_dump", "config_batch_set",
} {
found := false
for _, n := range names {
if n == want {
found = true
}
}
if !found {
t.Errorf("缺少工具 %s;实际 %v", want, names)
}
}
// 写工具必须串行;读工具必须可并发。
if !serial["config_set"] || !serial["config_batch_set"] {
t.Error("写工具未声明 Serial")
}
for _, ro := range []string{"config_list_plugins", "config_get", "config_list_keys", "config_get_defs", "config_dump"} {
if !parallel[ro] {
t.Errorf("只读工具 %s 未声明 ParallelSafe", ro)
}
}
}
func TestStopIsSafe(t *testing.T) {
p := cfgmgr.New("x")
sdk := sdkpkg.New("x", sdkpkg.SDKConfig{RegTool: func(string, sdkpkg.ToolDef, sdkpkg.ToolHandler) error { return nil }})
if err := p.Start(sdk); err != nil {
t.Fatal(err)
}
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
}

View File

@ -0,0 +1,120 @@
package test
// mcp 的黑盒判据:httptest 提供真实 HTTP JSON-RPC 输入,
// 通过公开 NewSSETransport/NewServer/ListTools/CallTool 观察输出。
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugins/mcp"
)
func rpcServer(t *testing.T, handler func(method string, body []byte) (status int, ct string, resp string)) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct{ Method string }
_ = json.Unmarshal(body, &req)
st, ct, resp := handler(req.Method, body)
w.Header().Set("Content-Type", ct)
w.WriteHeader(st)
_, _ = w.Write([]byte(resp))
}))
t.Cleanup(srv.Close)
return srv
}
func newClient(t *testing.T, url string) *mcp.Server {
t.Helper()
s := mcp.NewServer("contract-mcp", mcp.NewSSETransport(url))
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestListToolsThroughHTTP(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
if method != "tools/list" {
t.Errorf("method = %q,期望 tools/list", method)
}
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"read_file","description":"读取文件"},{"name":"write_file","description":"写文件"}]}}`
})
tools, err := newClient(t, srv.URL).ListTools()
if err != nil {
t.Fatalf("ListTools: %v", err)
}
if len(tools) != 2 || tools[0].Name != "read_file" || tools[1].Name != "write_file" {
t.Fatalf("tools = %+v", tools)
}
}
func TestCallToolSuccessAndEmptyContentHandling(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
if method != "tools/call" {
t.Errorf("method = %q,期望 tools/call", method)
}
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"world"}],"isError":false}}`
})
out, err := newClient(t, srv.URL).CallTool("greet", map[string]interface{}{"x": 1})
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if out != "world" {
t.Fatalf("out = %q", out)
}
emptySrv := rpcServer(t, func(method string, body []byte) (int, string, string) {
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"content":[]}}`
})
if _, err := newClient(t, emptySrv.URL).CallTool("x", nil); err == nil || !strings.Contains(err.Error(), "未返回任何文本") {
t.Fatalf("空内容未被判失败:%v", err)
}
}
func TestCallToolSurfacesIsErrorAsFailure(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"id is required"}],"isError":true}}`
})
_, err := newClient(t, srv.URL).CallTool("x", nil)
if err == nil || !strings.Contains(err.Error(), "id is required") {
t.Fatalf("isError 未被判失败:%v", err)
}
}
func TestCallToolSurfacesRPCError(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"bad params"}}`
})
_, err := newClient(t, srv.URL).CallTool("x", nil)
if err == nil || !strings.Contains(err.Error(), "bad params") {
t.Fatalf("RPC error 未被透出:%v", err)
}
}
func TestCallToolSurfacesHTTPError(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
return 500, "text/plain", "boom"
})
_, err := newClient(t, srv.URL).CallTool("x", nil)
if err == nil || !strings.Contains(err.Error(), "http status 500") {
t.Fatalf("HTTP 错误未被透出:%v", err)
}
}
func TestListToolsParseSSEStreamFrame(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
frame := `{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"sse_tool","description":"d"}]}}`
return 200, "text/event-stream", "event: message\ndata: " + frame + "\n\n"
})
tools, err := newClient(t, srv.URL).ListTools()
if err != nil {
t.Fatalf("SSE LL: %v", err)
}
if len(tools) != 1 || tools[0].Name != "sse_tool" {
t.Fatalf("tools=%+v", tools)
}
}

View File

@ -0,0 +1,60 @@
package test
// remotedevice Registry 黑盒判据:通过公开方法观察结果保存、能力查询与媒体落盘。
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugins/remotedevice"
)
func TestSaveResultThenGetResult(t *testing.T) {
r := remotedevice.NewRegistry()
want := map[string]interface{}{"status": "ok", "output": "done"}
r.SaveResult("req-123", want)
got, ok := r.GetResult("req-123")
if !ok || got["status"] != "ok" || got["output"] != "done" {
t.Fatalf("GetResult=%v,%v", got, ok)
}
if _, ok := r.GetResult("missing"); ok {
t.Fatal("unknown request unexpectedly found")
}
}
func TestSupportsToolForUnknownAndUnknownCapability(t *testing.T) {
r := remotedevice.NewRegistry()
if r.SupportsTool("missing-device", "screensee") {
t.Fatal("unknown device supports tool")
}
}
func TestSaveInlineMediaRequiresDirectoryAndPersistsBytes(t *testing.T) {
r := remotedevice.NewRegistry()
payload := base64.StdEncoding.EncodeToString([]byte("media bytes"))
if _, err := r.SaveInlineMedia("capture", payload, "application/octet-stream"); err == nil {
t.Fatal("media saved without configured directory")
}
dir := t.TempDir()
r.SetMediaDir(dir)
meta, err := r.SaveInlineMedia("capture", payload, "application/octet-stream")
if err != nil {
t.Fatalf("SaveInlineMedia: %v", err)
}
path, ok := meta["file"].(string)
if !ok || filepath.Dir(path) != dir {
t.Fatalf("file metadata=%v", meta)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("media file missing: %v", err)
}
if string(got) != "media bytes" || meta["size"] != len(got) || meta["mime"] != "application/octet-stream" {
t.Fatalf("saved payload/metadata mismatch: %q / %v", got, meta)
}
if _, ok := meta["sha256"].(string); !ok {
t.Fatalf("missing digest: %v", meta)
}
}

View File

@ -0,0 +1,54 @@
package test
// skillmgr 黑盒判据:技能目录输入 → SkillDescriptions 可观察文本。
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugins/skillmgr"
sdkpkg "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
func TestSkillDescriptionsListsScannedSkillsInStableOrder(t *testing.T) {
dir := t.TempDir()
for name, desc := range map[string]string{"zeta": "处理 zeta 的工作流。", "alpha": "处理 alpha 的工作流。"} {
p := filepath.Join(dir, name)
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
body := "# " + name + "\n\n" + desc + "\n"
if err := os.WriteFile(filepath.Join(p, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
p := skillmgr.New("contract-skillmgr", dir)
// Start performs the public directory scan; provide only the two public
// registrar callbacks required by the plugin SDK and no hidden test hooks.
sdk := sdkpkg.New("contract-skillmgr", sdkpkg.SDKConfig{
RegTool: func(string, sdkpkg.ToolDef, sdkpkg.ToolHandler) error { return nil },
})
if err := p.Start(sdk); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = p.Stop() })
got := p.SkillDescriptions()
if !strings.Contains(got, "alpha: 处理 alpha 的工作流。") || !strings.Contains(got, "zeta: 处理 zeta 的工作流。") {
t.Fatalf("scanned descriptions missing: %q", got)
}
if strings.Index(got, "alpha:") > strings.Index(got, "zeta:") {
t.Fatalf("descriptions not in stable name order: %q", got)
}
if p.Name() != "contract-skillmgr" {
t.Fatalf("Name=%q", p.Name())
}
}
func TestSkillDescriptionsEmptyBeforeStart(t *testing.T) {
p := skillmgr.New("empty", t.TempDir())
if got := p.SkillDescriptions(); got != "" {
t.Fatalf("unexpected descriptions: %q", got)
}
}

View File

@ -0,0 +1,68 @@
package test
// internal/sdk 的模块级黑盒判据:公开 SDK 包装 API 的输入→输出。
import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
func TestEffectiveProxyAuthNormalizesLegacyAndEmpty(t *testing.T) {
for _, in := range []string{"", sdk.ProxyAuthAgentLegacy} {
if got := sdk.EffectiveProxyAuth(in); got != sdk.ProxyAuthAgent {
t.Errorf("EffectiveProxyAuth(%q)=%q", in, got)
}
}
if got := sdk.EffectiveProxyAuth(sdk.ProxyAuthNone); got != sdk.ProxyAuthNone {
t.Fatalf("explicit no-auth changed: %q", got)
}
}
func TestProxyHostNormalizationAndValidation(t *testing.T) {
label := sdk.NormalizeProxyHost("My Plugin")
if label == "" || !sdk.ValidProxyHostLabel(label) {
t.Fatalf("normalized host %q is not a valid DNS label", label)
}
if sdk.ValidProxyHostLabel("bad..label") || sdk.ValidProxyHostLabel("-leading") {
t.Fatal("invalid DNS host label accepted")
}
}
func TestNewConfigGetPutRoundTrip(t *testing.T) {
first := &types.Config{LLM: types.LLMConfig{Provider: "first"}}
api := sdk.NewConfig(first)
if got := api.Get(); got != first {
t.Fatal("Get did not return configured value")
}
second := &types.Config{LLM: types.LLMConfig{Provider: "second"}}
api.Put(second)
if got := api.Get(); got != second {
t.Fatal("Put value not observable via Get")
}
}
func TestBuiltinProxyRegistrySnapshotsAndClear(t *testing.T) {
const name = "contract-sdk-proxy"
sdk.ClearBuiltinProxyDefs(name)
t.Cleanup(func() { sdk.ClearBuiltinProxyDefs(name) })
before := sdk.BuiltinProxyVersion()
sdk.RegisterBuiltinProxy(name, "web", sdk.ProxyDef{Target: "http://127.0.0.1:1234"})
defs := sdk.BuiltinProxyDefs()
if len(defs[name]) != 1 || defs[name][0].Name != "web" {
t.Fatalf("registered proxy not observable: %+v", defs[name])
}
if sdk.BuiltinProxyVersion() <= before {
t.Fatal("registration did not advance public version")
}
// Returned slice is a snapshot; mutating it must not mutate the registry.
defs[name][0].Target = "mutated"
if got := sdk.BuiltinProxyDefs()[name][0].Target; got != "http://127.0.0.1:1234" {
t.Fatalf("snapshot mutation leaked into registry: %q", got)
}
sdk.ClearBuiltinProxyDefs(name)
if _, ok := sdk.BuiltinProxyDefs()[name]; ok {
t.Fatal("ClearBuiltinProxyDefs did not remove entry")
}
}

View File

@ -4,6 +4,8 @@ package test
// 不读源码、不碰 Codec/Store 内部字段、不用任何 ForTest 钩子。
import (
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/security"
@ -138,3 +140,109 @@ func TestCodecOpenRejectsCorruptedCiphertext(t *testing.T) {
}
}
}
// ---- Gate 的模块职责:明文凭据在出向被换成别名,执行前再回填原值 ----
func newGateStore(t *testing.T) *security.Store {
t.Helper()
st := security.New(security.Options{
Path: filepath.Join(t.TempDir(), "vars.json"),
KeyFn: func() ([]byte, error) { return fixedKey(11), nil },
Enabled: true,
})
if err := st.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
return st
}
const contractSecret = "sk-proj-abcdefghijklmnopqrstuvwxyz0123456789"
func TestGateReplacesSecretOutboundAndBackfillsForExecution(t *testing.T) {
store := newGateStore(t)
gate := security.NewGate(store)
out, n := gate.PrepareOutbound(security.Inspection{Stage: "to_llm"}, "key="+contractSecret)
if n == 0 {
t.Fatal("outbound secret was not intercepted")
}
if strings.Contains(out, contractSecret) {
t.Fatalf("outbound text still carries plaintext: %q", out)
}
// 出向文本里给出的是别名;执行前必须换回真实凭据。
alias := extractAlias(out)
if alias == "" {
t.Fatalf("alias not found in outbound text: %q", out)
}
back, filled := gate.FillForExecution(security.Inspection{Stage: "to_plugin"}, "key=@{"+alias+"}")
if filled != 1 {
t.Fatalf("backfill reported %d places", filled)
}
if back != "key="+contractSecret {
t.Fatalf("backfilled text = %q", back)
}
}
func TestGateScrubsSecretInbound(t *testing.T) {
gate := security.NewGate(newGateStore(t))
out, n := gate.ScrubInbound(security.Inspection{Stage: "tool_result"}, "leaked "+contractSecret)
if n == 0 || strings.Contains(out, contractSecret) {
t.Fatalf("inbound scrub failed: n=%d out=%q", n, out)
}
}
func TestGateDisabledPassesTextThrough(t *testing.T) {
st := security.New(security.Options{Enabled: false,
KeyFn: func() ([]byte, error) { return fixedKey(11), nil }})
gate := security.NewGate(st)
in := "key=" + contractSecret
if out, n := gate.PrepareOutbound(security.Inspection{}, in); out != in || n != 0 {
t.Fatalf("disabled gate modified text: %q n=%d", out, n)
}
if !gate.Enabled() == true {
t.Fatal("unexpected Enabled() result for disabled store")
}
}
func TestStoreAliasRoundTripSurvivesReopen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "vars.json")
first := security.New(security.Options{Path: path,
KeyFn: func() ([]byte, error) { return fixedKey(11), nil }, Enabled: true})
if err := first.Load(); err != nil {
t.Fatal(err)
}
alias, err := first.Submit(contractSecret, "contract")
if err != nil {
t.Fatalf("Submit: %v", err)
}
if alias == "" || alias == contractSecret {
t.Fatalf("alias = %q", alias)
}
second := security.New(security.Options{Path: path,
KeyFn: func() ([]byte, error) { return fixedKey(11), nil }, Enabled: true})
if err := second.Load(); err != nil {
t.Fatal(err)
}
back, n, _ := second.Fill("@{" + alias + "}")
if n != 1 || !strings.Contains(back, contractSecret) {
t.Fatalf("fill after reopen = %q (n=%d)", back, n)
}
}
// extractAlias 从安全模块产物里取出别名(公开契约:@\{alias\})。
func extractAlias(text string) string {
const marker = "@{"
i := strings.Index(text, marker)
if i < 0 {
return ""
}
rest := text[i+len(marker):]
j := strings.Index(rest, "}")
if j < 0 {
return ""
}
return rest[:j]
}

View File

@ -1,7 +1,7 @@
package test
// internal/system 的黑盒判据:导出路径判定函数,输入 → 输出。
// 不改全局状态、不碰系统文件。
// internal/system 的模块级黑盒判据:受保护路径的写前留档与恢复,
// 用真实文件系统输入 → 可观察文件内容输出。
import (
"os"
@ -11,64 +11,99 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/system"
)
// TestIsProtectedPathExplicit:显式前缀集内的路径判受保护,集外的不判。
func TestIsProtectedPathExplicit(t *testing.T) {
dir := t.TempDir()
inside := filepath.Join(dir, "protected", "file.txt")
outside := filepath.Join(dir, "other", "file.txt")
prefixes := []string{filepath.Join(dir, "protected")}
if !system.IsProtectedPathExplicit(prefixes, inside) {
t.Fatal("★ 前缀集内的路径没判受保护")
}
if system.IsProtectedPathExplicit(prefixes, outside) {
t.Fatal("★ 前缀集外的路径被判受保护")
}
if system.IsProtectedPathExplicit(nil, inside) {
t.Fatal("★ 空前缀集却判受保护")
}
}
// TestArchiveBeforeWrite:覆盖前留档,返回 archived=true 且原文可读。
func TestArchiveBeforeWrite(t *testing.T) {
// TestArchiveAndRestoreRoundTrip 覆盖 guard 的完整职责:
// 受保护文件被覆盖前留档 → 覆盖后可恢复成原内容。
func TestArchiveAndRestoreRoundTrip(t *testing.T) {
base := t.TempDir()
// 用导出的配置入口把该目录设为受保护范围(不改全局默认值的语义之外)
protected := filepath.Join(base, "etc")
system.SetProtectedPaths([]string{protected + string(os.PathSeparator)})
t.Cleanup(func() { system.SetProtectedPaths(nil) })
if err := os.MkdirAll(protected, 0755); err != nil {
if err := os.MkdirAll(protected, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(protected, "config.txt")
if err := os.WriteFile(target, []byte("original"), 0644); err != nil {
const original = "original-content"
if err := os.WriteFile(target, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
archived, err := system.ArchiveBeforeWrite(base, target)
if err != nil {
t.Fatalf("ArchiveBeforeWrite: %v", err)
}
if !archived {
t.Fatal("★ 已存在的文件却没留档")
t.Fatal("existing protected file was not archived")
}
// 留档目录是导出的 FileBaselineDir
baseline := system.FileBaselineDir(base)
if baseline == "" {
t.Fatal("★ FileBaselineDir 为空")
// 调用方随后覆盖文件(模拟 L0 写入)。
if err := os.WriteFile(target, []byte("broken-update"), 0o644); err != nil {
t.Fatal(err)
}
restored, err := system.RestoreFileFromBaseline(base, target)
if err != nil {
t.Fatalf("RestoreFileFromBaseline: %v", err)
}
if !restored {
t.Fatal("restore reported no baseline")
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != original {
t.Fatalf("restored content = %q, want %q", got, original)
}
}
// TestNetworkBaselinePathIsStable:路径输出稳定且落在 dataDir 下。
func TestNetworkBaselinePathIsStable(t *testing.T) {
dir := t.TempDir()
a := system.NetworkBaselinePath(dir)
b := system.NetworkBaselinePath(dir)
if a == "" || a != b {
t.Fatalf("★ NetworkBaselinePath 不稳定:%q vs %q", a, b)
// TestArchiveSkipsUnprotectedAndNewFiles:非受保护路径与新建文件都不留档。
func TestArchiveSkipsUnprotectedAndNewFiles(t *testing.T) {
base := t.TempDir()
protected := filepath.Join(base, "etc")
other := filepath.Join(base, "home")
system.SetProtectedPaths([]string{protected + string(os.PathSeparator)})
t.Cleanup(func() { system.SetProtectedPaths(nil) })
for _, d := range []string{protected, other} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
unprotected := filepath.Join(other, "file.txt")
if err := os.WriteFile(unprotected, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if archived, err := system.ArchiveBeforeWrite(base, unprotected); err != nil || archived {
t.Fatalf("unprotected file archived=%v err=%v", archived, err)
}
newFile := filepath.Join(protected, "does-not-exist.txt")
if archived, err := system.ArchiveBeforeWrite(base, newFile); err != nil || archived {
t.Fatalf("new file archived=%v err=%v", archived, err)
}
if _, err := os.Stat(filepath.Join(system.FileBaselineDir(base), "etc", "does-not-exist.txt")); err == nil {
t.Fatal("baseline written for a file that did not exist")
}
}
// TestLoadBaselineMissingReportsError:读不存在的基线必须报错。
func TestLoadBaselineMissingReportsError(t *testing.T) {
if _, err := system.LoadNetworkBaseline(t.TempDir()); err == nil {
t.Fatal("★ 读不存在的基线却返回成功")
// TestArchiveIsIdempotentForUnchangedContent:内容未变时重复留档不再产生新档案。
func TestArchiveIsIdempotentForUnchangedContent(t *testing.T) {
base := t.TempDir()
protected := filepath.Join(base, "etc")
system.SetProtectedPaths([]string{protected + string(os.PathSeparator)})
t.Cleanup(func() { system.SetProtectedPaths(nil) })
if err := os.MkdirAll(protected, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(protected, "same.txt")
if err := os.WriteFile(target, []byte("same"), 0o644); err != nil {
t.Fatal(err)
}
if archived, err := system.ArchiveBeforeWrite(base, target); err != nil || !archived {
t.Fatalf("first archive: archived=%v err=%v", archived, err)
}
if archived, err := system.ArchiveBeforeWrite(base, target); err != nil || archived {
t.Fatalf("second archive on unchanged content: archived=%v err=%v", archived, err)
}
}

View File

@ -1,78 +1,101 @@
package test
// internal/tracker 的黑盒判据:NewTracker/PreAction/PostAction → 可观察输出。
// 不碰 t.changeSets、不读内部目录结构(只用公开方法返回的结果)。
// internal/tracker 的模块级黑盒判据:真实文件变更 → changeset → 回滚恢复文件。
// 使用 upper 目录作为可写工作区的真实输入,不碰 t.changeSets 之外无公开替代的状态。
import (
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/tracker"
)
func newTracker(t *testing.T) *tracker.Tracker {
func newTracker(t *testing.T) (*tracker.Tracker, string) {
t.Helper()
dir := t.TempDir()
tr := tracker.NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
if err := tr.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
return tr
t.Cleanup(func() { _ = tr.Stop() })
return tr, filepath.Join(dir, "work", "upper")
}
// TestFreshTrackerHasNoChanges:新 tracker 无 changeset。
func TestFreshTrackerHasNoChanges(t *testing.T) {
tr := newTracker(t)
if tr.HasChanges() {
t.Fatal("★ 新 tracker 报告有变更")
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if got := tr.ChangeSets(); len(got) != 0 {
t.Fatalf("★ ChangeSets() = %v,期望空", got)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// TestNewChangeSetCarriesAction:构造出的 changeset 带 action/ID/时间。
func TestNewChangeSetCarriesAction(t *testing.T) {
cs := tracker.NewChangeSet("write_file")
if cs.Action != "write_file" {
t.Fatalf("★ Action = %q", cs.Action)
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if cs.ID == "" || cs.Timestamp.IsZero() {
t.Fatalf("★ ID/时间未填:%+v", cs)
return string(b)
}
// TestPostActionRecordsRealFileChange:真实新建文件后 PostAction 必须记录该文件。
func TestPostActionRecordsRealFileChange(t *testing.T) {
tr, upper := newTracker(t)
tr.PreAction("write_file")
writeFile(t, filepath.Join(upper, "new.txt"), "hello")
cs := tr.PostAction("write_file")
if len(cs.Files) != 1 || cs.Files[0].Path != "new.txt" || cs.Files[0].Type != tracker.ChangeFileCreated {
t.Fatalf("changeset = %+v", cs.Files)
}
if !tr.HasChanges() {
t.Fatal("HasChanges false after a tracked change")
}
}
// TestPostActionWithoutFileChangeIsNoop:没有文件变更时 PostAction
// 不得凭空造出 changeset。
func TestPostActionWithoutFileChangeIsNoop(t *testing.T) {
tr := newTracker(t)
_ = tr.PreAction("write_file")
got := tr.PostAction("write_file")
if len(got.Files) != 0 {
t.Fatalf("★ 无文件变更却记录了 %d 条", len(got.Files))
// TestRollbackLatestRestoresModifiedContent:修改已存在文件的原文被回滚恢复。
func TestRollbackLatestRestoresModifiedContent(t *testing.T) {
tr, upper := newTracker(t)
const original = "original"
const changed = "changed"
writeFile(t, filepath.Join(upper, "cfg.txt"), original)
tr.PreAction("edit")
writeFile(t, filepath.Join(upper, "cfg.txt"), changed)
tr.PostAction("edit")
if got := readFile(t, filepath.Join(upper, "cfg.txt")); got != changed {
t.Fatalf("precondition: %q", got)
}
if tr.HasChanges() {
t.Fatal("★ 无文件变更却 HasChanges=true")
if err := tr.RollbackLatest(); err != nil {
t.Fatalf("RollbackLatest: %v", err)
}
if got := readFile(t, filepath.Join(upper, "cfg.txt")); got != original {
t.Fatalf("after rollback = %q, want %q", got, original)
}
}
// TestRollbackLatestWithoutChangesReportsError:无 changeset 时定向回滚报错。
// TestRollbackLatestRemovesCreatedFile:新建文件被回滚时应当消失。
func TestRollbackLatestRemovesCreatedFile(t *testing.T) {
tr, upper := newTracker(t)
tr.PreAction("write_file")
path := filepath.Join(upper, "temp.txt")
writeFile(t, path, "scratch")
tr.PostAction("write_file")
if err := tr.RollbackLatest(); err != nil {
t.Fatalf("RollbackLatest: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("created file survived rollback (err=%v)", err)
}
}
// TestRollbackLatestWithoutChangesReportsError:没有变更时定向回滚必须明确报错。
func TestRollbackLatestWithoutChangesReportsError(t *testing.T) {
tr := newTracker(t)
tr, _ := newTracker(t)
if err := tr.RollbackLatest(); err == nil {
t.Fatal("★ 无 changeset 时 RollbackLatest 应报错")
}
}
// TestRollbackClearsChangeSets:Rollback 后视图清空(无需挂载 overlay)。
func TestRollbackClearsChangeSets(t *testing.T) {
tr := newTracker(t)
_ = tr.PreAction("a")
_ = tr.PostAction("a")
if err := tr.Rollback(); err != nil {
t.Fatalf("Rollback: %v", err)
}
if tr.HasChanges() {
t.Fatal("★ Rollback 后仍有变更")
t.Fatal("RollbackLatest accepted empty history")
}
}