|
|
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 |
|
|
|
cd4290807c
|
test: 按模块导出接口重写黑盒判据(批次 12:plugin / qwen3vl / chineseclip)
3 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
新增公开输入→输出判据:
## internal/plugin —— SKILL 内容与加载
TestValidateSKILLContent
合法内容通过;空内容/无描述/非法工具名分别被拒
TestLoadSKILLDirectoryExposesMetadataAndTools
临时 SKILL.md → Name/Version/Description/Tools/IOConfig
TestLoadSKILLMissingFileReturnsError
## providers/qwen3vl —— BPE tokenizer 公开契约
TestTokenizerSpecialTokenRoundTrip
临时 tokenizer.json → VocabSize/SpecialID/Encode/DecodeOne
TestTokenizerMissingFileReturnsError
## providers/chineseclip —— WordPiece tokenizer 公开契约
TestTokenizerEncodeUsesSpecialTokensAndPadding
临时 vocab.txt → 固定长度 ids 与 attention mask
TestTokenizerRejectsInvalidVocabulary
缺特殊 token / 非正 maxLength → 报错
所有测试都用 `package test` 导入模块,通过导出 API 操作;不访问私有符号,
不读取源码/文档作为业务断言。
## 变异验证
plugin ValidateSKILLContent 放过无描述内容 → no_description 断言红
qwen3vl Encode 丢弃特殊 token → Encode=[] 断言红
chineseclip Encode 不补 padding → 固定长度判据红
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 45 个测试包 / 100 包无测试 / 0 FAIL
3 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 16:35:21 +08:00 |
|
|
|
08cbd3e7bd
|
test: 按模块导出接口重写黑盒判据(批次 11:update / kernelboot / devicebridge)
3 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 15 条判据,只调导出接口,输入 → 可观察输出。
## internal/update —— 版本/升级纯函数
TestParseSemver 三段式(可带 v);非纯版本返回 nil
TestCmpSemver 大小/相等;nil 返回 0(不可比)
TestNewer 只认纯三段式;dev 后缀不误报
TestCompareVersions 升级/降级/完全相同/同版本不同 commit
TestDeltaAssetName 产物名内联版本/平台/来源 commit
TestShortCommit 长 commit 截 8 位
TestBrokenDirPath 输出落在 dataDir 下且含时间戳
## internal/kernelboot —— 依赖图编解码
TestGraphEncodeDecodeRoundTrip Encode → Decode 模块与层号
TestGraphDigestIsDeterministic 同声明稳定;换声明则变
TestDecodeGraphRejectsCorruption 位翻转/截断必拒
TestCheckAgainstBinary dev 放行 / 不配套拒 / 配套通过
TestFuncModuleExposesSpec Name/Provides/Requires
**发现一条假绿并修掉**:`TestDecodeGraphRejectsCorruption` 第一版
翻转 `data[10]`(magic/长度区),跳过 hash 校验后仍会因格式标识报错
⇒ 判据假绿。改成只翻转尾部 hash 之前的最后一个 payload 字节,
让唯一能抓到它的就是 hash 自校验,变异才真红。
## internal/devicebridge/client —— BaseResult
TestBaseResultAlwaysCarriesIdentity op/req_id/status
TestBaseResultOmitsEmptyOptionalFields 空 output/error 不进结果
TestBaseResultCarriesOutputAndError 非空时落对应键
## 变异验证(改回旧实现必须红)
update CmpSemver 恒 0 → ★ 版本大小比较错误
kernelboot 跳过 hash 校验 → ★ 数据区位翻转的图被接受
devicebridge 丢 req_id → ★ 身份字段缺失
kernelboot 第一版变异造成「未使用变量 → 编译失败」,不算干净断言红;
改成跳过校验但保留变量后确认判据有效。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 42 个测试包 / 100 包无测试 / 0 FAIL
3 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 14:59:40 +08:00 |
|
|
|
3420ceca60
|
test: 按模块导出接口重写黑盒判据(批次 10:agent / lua / ollama / log)
4 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 20 条判据,只调导出接口,输入 → 可观察输出。
## internal/agent —— 人格文件
TestSaveThenLoadPersonality Save → Load 内容/路径
TestLoadMissingPersonalityGivesEmpty 文件不存在 → 空人格
TestInjectPromptContainsContentAndRules 注入含原文 + 内核说话规则
TestInjectPromptEmptyPersonalityIsEmpty
TestPersonaStaleHints 陈旧措辞给提示;干净文本不报
TestSavePersonalityCreatesParentDirs 自动建父目录
## internal/lua —— VM 适配器
TestNewVMExposesAdapterDir 构造参数 → AdapterDir
TestListAdaptersEmptyThenLoaded LoadAdapterSource → ListAdapters
TestLoadAdapterSourceRejectsBadScript
TestCallTransformRequestUnknownAdapter
TestGetAdapterEndpointUnknownIsEmpty
## providers/ollama —— httptest 真实 HTTP
TestGenerateReturnsText 200 → Response.Text
TestGenerateMarksTruncated done_reason=length → Truncated
TestGenerateHTTPError 5xx → 报错
TestGenerateRejectsInvalidJSONSchema
TestNewFallsBackToDefaultModel 空 model 走默认值
**判据写错过一次**:第一版断言"空 model 构造失败",但 `optString`
对空值回退默认,构造成功才是真实契约,按此修正。
## internal/log —— Manager 生命周期
TestManagerStartStopIsSafe Start/cancel/Stop 不挂住
TestManagerStopIsIdempotent 连停两次不 panic
TestRetentionPoliciesAreDistinct 四个策略常量互异
## 变异验证(改回旧实现必须红)
agent SavePersonality 不建父目录 → ★ 文件没落盘
lua ListAdapters 恒空 → ★ ListAdapters = [],期望 1 个
ollama 丢 Truncated → ★ done_reason=length 未标记 Truncated
log Stop 重复 close → panic: close of closed channel
lua / log 第一版变异分别是文本不匹配与"import 变未使用导致编译失败",
都不算干净的断言红;重做成只改行为后确认有效。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 39 个测试包 / 100 包无测试 / 0 FAIL
4 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 14:41:43 +08:00 |
|
|
|
b2a5fad95f
|
test: 按模块导出接口重写黑盒判据(批次 9:seq / nlp / pipeline)
3 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 17 条判据,只调导出接口,输入 → 可观察输出。
`internal/kernelmod/pluginhost` 本批跳过:它的 PluginCatalog 只有
`newCatalog` 这个未导出构造,没有对外入口。按「不为测试导出内部」的
规矩,不为它造壳。
## internal/plugins/seq —— Parse / Store
TestParseValidSequence 合法 JSON → 名字/Boot/Groups
TestParseRejectsUnknownField 拼错字段必须报 unknown field
TestParseRejectsInvalidJSON 坏 JSON 报错
TestStoreRoundTrip Save → List/Load → Delete
TestLoadMissingReportsError
TestCheckNewRejectsSelfRecursion #self 调用被拒
TestCheckNewAcceptsValidCall 合法 group 调用通过
**判据写错过三次,都按真实输入契约修正**:
① group 的工具字段叫 `tools` 不叫 `steps`;
② `tools` 在 JSON 里是字符串(若干以 `;` 分隔的 JSON 对象),不是数组;
③ 最后一个工具末尾必须有 `;`。
`CheckNew` 守的是 seq_call 的 target 校验,不是"同名重复",判据按此改。
**还发现一条假绿并修掉**:`TestParseRejectsUnknownField` 第一版只断言
`err != nil`,但输入没带 group —— "没有任何 group"同样会报错,
去掉 `DisallowUnknownFields` 后判据仍绿。改成带合法 group
且断言错误里含 `unknown field`,变异才真红。
## internal/nlp —— 配置 / Extractor
TestDefaultExtractorConfig 默认权重可用
TestSetFusionWeightsRoundTrip 设入 → FusionConfig 读回
TestSetDefaultParserRoundTrip SetDefaultParser → GetDefaultParser
TestExtractEmptyTextReturnsEmptySet
TestExtractNonEmptyTextDoesNotPanic
不带 ONNX 模型时默认 parser 为 nil、`NewExtractor(nil)` 走 fallback,
这是真实契约;判据不要求它非 nil,而是用 stub 验证 Set/Get 往返。
## internal/memory/pipeline —— Distiller
TestAppendThenRecentRecords Append → GetRecentRecords
TestGetRecentRecordsRespectsLimit limit + 取最新 N 条
TestPauseResumeStopped 暂停/恢复可观察
TestStatsIsUsable
TestRawPathIsUnderDataDir
## 变异验证(改回旧实现必须红)
seq 去掉 DisallowUnknownFields → ★ 未知字段被静默接受
nlp SetFusionWeights 丢 alpha → ★ FusionConfig 不符
pipeline Append 不记录 → ★ GetRecentRecords = 0 条
全部还原后复跑全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 35 个测试包 / 100 包无测试 / 0 FAIL
3 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 14:20:59 +08:00 |
|
|
|
ac525670d1
|
test: 按模块导出接口重写黑盒判据(批次 8:embedding / generation / text / social)
4 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 18 条判据,只调导出接口,输入 → 可观察输出。
## pkg/embedding —— 注册表 + 校验
TestRegisterThenOpen 注册 → Names/Open → Info/Embed 输出
TestOpenUnknownProviderReportsError 未注册名报错且可诊断
TestValidateInfo 维度/指纹真值表
TestValidateVector 维度不符 / NaN / Inf 必拒
## pkg/generation —— 注册表
TestRegisterThenOpen 注册 → Names/Open → Info/Generate 输出
TestOpenUnknownProviderReportsError 未注册名报错
## internal/memory/text —— 真实临时目录
TestAppendThenRecentEvents Append → RecentEvents 读回内容
TestReplayVisitsAppendedEvents Replay 收到全部事件
TestFileCountAndStats 写后文件数/统计可用
## internal/memory/social —— 真实内存图库
TestNilDBReportsError 无图库时写操作报错(不静默)
TestSetTraitThenGetTrait 特质往返
TestSetTraitOverwrites 同键覆盖
TestAddRelationThenGetRelations 关系往返
TestListPersons 人物列表含已写入的人
## 变异验证(改回旧实现必须红)
embedding 非有限值检查留 NaN 通过 → ★ 非法向量被接受:[NaN 2 3]
generation 未注册名静默返回 nil → ★ 未注册 provider 的报错不可诊断
text Append 不写盘 → ★ RecentEvents = 0 条
social SetTrait 不写 → ★ 无图库却 SetTrait 成功
前两条第一版变异分别造成「编译失败(math 未使用)」和「panic」,
不是干净的断言红 —— 改成只改行为、仍能编译的变异后确认有效。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 32 个测试包 / 100 包无测试 / 0 FAIL
4 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 13:55:15 +08:00 |
|
|
|
379300d89c
|
test: 按模块导出接口重写黑盒判据(批次 7:agent-io / system / ipc / supervisor)
4 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 15 条判据,只调导出接口,输入 → 可观察输出。
## internal/agent/io —— OutputCapability
TestSupportsSingleAndCombinedFlags 位标志的「任一位命中」语义
TestCapabilityStringListsFlags String 列出已置位能力名
TestZeroCapabilityString 零能力不 panic
**判据写错过一次**:第一版按「全部命中」写 Supports,实测红。
查实现(`c&cap != 0`)确认是任一位命中,按真实契约修正。
## internal/system —— 路径判定 / 写前留档
TestIsProtectedPathExplicit 显式前缀集内/外的判定
TestArchiveBeforeWrite 受保护路径覆盖前留档(走导出的
SetProtectedPaths 配置入口)
TestNetworkBaselinePathIsStable 路径输出稳定
TestLoadBaselineMissingReportsError 读不存在的基线报错
## internal/ipc —— 真实 unix socket 往返
TestPingReturnsServerStatus Server 状态原样到达 Client
TestPingWithoutServerReportsError 无服务端时报错
用临时目录下真 socket,不 mock 传输层。
## internal/supervisor —— Daemon 登记
TestFreshDaemonHasNoAgents 新 daemon 无 agent
TestRegisterThenList RegisterAgent → List/Get 反映该 agent
TestGetUnknownAgentReportsError 未注册 agent 报错
TestSDKAdapterSeesSameAgents 适配层看到同一份登记
## 变异验证(改回旧实现必须红)
agent/io Supports 恒 false → ★ 已置位的能力报告不支持
system 路径判定恒 false → ★ 前缀集内的路径没判受保护
ipc Ping 丢 Tools 字段 → ★ Ping 拿到的状态不符
supervisor ListAgents 恒空 → ★ ListAgents = [],期望 1 个
system / ipc 第一版变异改错了位置(空变异),重做后确认判据有效。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 28 个测试包 / 100 包无测试 / 0 FAIL
4 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 13:38:31 +08:00 |
|
|
|
8814ccdef6
|
test: 按模块导出接口重写黑盒判据(批次 6:promptsec / toolroute / pluginview / meta)
4 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 21 条判据,只调导出接口,输入 → 可观察输出。
## internal/promptsec —— NewRegistry/Declare/Render/Snapshot/Names
TestDeclareThenRender 声明两段,按 Order 输出且含正文
TestDeclareRejectsMismatchedOwner 段名前缀与 Owner 不符必须报错
TestDeclareIsIdempotent 同段名覆盖,不产生两份
TestEmptyContentRemovesSection 空正文 = 撤销声明
TestRemoveOwnerOnlyDropsThatOwner 撤销一个 owner 不影响其他
TestSnapshotReturnsAllSections 快照反映当前声明
## internal/toolroute —— Dispatch
TestDispatchRoutesEachPrefix 15 种工具前缀 → 对应族处理器
TestDispatchGetPluginToolsExtractsArg plugin_name 参数透传
TestDispatchUnknownIsNotSwallowed 未知工具 handled=false(插件工具不能消失)
TestDispatchNilHandlers nil handlers 不 panic
用 recordingHandlers 记录哪个族被调用,不断言内部 switch 结构。
## internal/pluginview —— 事件 → Snapshot
TestFreshViewIsEmpty 新视图为空
TestCmdRunEventAppearsInSnapshot cmd_run → 命令历史
TestTerminalCreateThenOutput terminal_create + 输出 → 终端状态
TestUnknownToolIsIgnored 未知工具不造记录
**判据写错过一次**:`OnTerminalOutput` 的 payload 键是 `terminal_id`
不是 `id`,第一版按 `id` 写 ⇒ 输出没进 Snapshot。按真实输入契约修正。
## internal/meta —— 版本元信息
TestFullVersionContainsKernelAndVersion
TestSourceURLLooksLikeHTTPS
TestLicenseMetadataNotEmpty
TestSDKCompatibleVersionIsSemverLike
## 变异验证(改回旧实现必须红)
promptsec Declare 直接 return nil → ★ Render 缺正文
toolroute 未知工具 handled=true → ★ 未知工具被吞掉
pluginview cmd_run 不记录 → ★ cmd_run 未进命令历史
meta FullVersion 丢版本 → ★ 不含 "1.4.0"
第一版 promptsec 变异改的是条件中的一段 token、没改变行为(空变异),
重做后确认判据有效。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 24 个测试包 / 100 包无测试 / 0 FAIL
4 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 13:22:35 +08:00 |
|
|
|
5a0ff10d8d
|
test: 按模块导出接口重写黑盒判据(批次 5:vector / systemd / recovery)
3 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 17 条判据,只调导出接口,输入 → 可观察输出。
## internal/memory/vector —— 纯数学 + Store
TestCosineSimilarity 稀疏向量:同向/正交/零向量
TestDenseCosine 稠密向量:三种基本情形 + 非单位向量
TestStoreInsertSearchRemove Insert → Search 命中 → Remove → 不再命中
**变异第一版没红**:`DenseCosine` 去掉归一化后,我给的 `{1,0}`
用例点积恰好还是 1 ⇒ 空变异。补 `{4,4} / {2,2}`(不归一化得 16,
归一化得 1)后才真红。**变异必须真的改变被测行为**,这条按纪律记下。
## internal/systemd —— State / NewMigratePlan
TestStateInSync 相同/等价/空/不同路径
TestStateDescribe 一致→空;不一致→含两路径与后果
TestSyncCommand 不一致时给出可执行命令
TestNewMigratePlanNormalizesPaths 路径 Clean;脚本内联新旧路径 + shebang
TestRestartCommand 命令含给定地址
不读单元文件、不执行脚本、不碰系统状态。
## internal/recovery —— 文件往返
TestTaskRoundTrip SaveTask → LoadTask 全字段 + Plugins()
TestTaskPathsAreStable TaskPath/ResultPath 稳定且互异
TestResultRoundTrip SaveResult → LoadResult 结论字段
TestLoadTaskMissingFileReportsError
TestSortSteps 输出可用
## 变异验证(改回旧实现必须红)
vector 不归一化 → ★ 非单位同向 = 16,期望 1
systemd InSync 恒 true → ★ 真不一致: InSync=true
recovery 丢 PluginList → ★ PluginList 丢失:[]
全部还原后复跑全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 20 个测试包 / 100 包无测试 / 0 FAIL
3 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 13:08:16 +08:00 |
|
|
|
aac11f6661
|
test: 按模块导出接口重写黑盒判据(批次 4:coldcontext / tracker / network)
3 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 15 条判据,只调导出接口,输入 → 可观察输出。
## internal/memory/coldcontext —— NewStore/Insert/Get/Query/Consume/Blocks
TestInsertThenGet 空 ID 自动补齐;Get 拿回内容;时间戳填好
TestInsertExplicitIDKeepsIt 显式 ID 不被覆盖
TestQueryReturnsRelevantDoc 按分词器词形召回相关文档
TestConsumeIsOneShot 消费过的文档不再返回
TestBlocksReflectsInsertedDoc Blocks 输出可调用
**判据写错过一次并修正**:分词器按空白切词,文档里「发票重试」是一个
token,所以「发票 重试 账单」查不到而「发票重试」能查到 —— 实测探针
确认后按真实输入契约写。判据的输入必须能走到被测路径。
## internal/tracker —— NewTracker/Init/PreAction/PostAction/Rollback
TestFreshTrackerHasNoChanges 新 tracker 无 changeset
TestNewChangeSetCarriesAction Action/ID/时间
TestPostActionWithoutFileChangeIsNoop 无文件变更不凭空造 changeset
TestRollbackLatestWithoutChangesReportsError
TestRollbackClearsChangeSets Rollback 后视图清空
不碰 t.changeSets、不假设 upper/merge 目录结构。
## internal/network —— NewMonitor/CheckOnce/Status/AggregateResult
TestCheckOnceReachableServer 2xx → Reachable=true + URL/时间
TestCheckOnceServerError 5xx → Reachable=false
TestCheckOnceUnreachableEndpoint 连不上 → Error 非空,不 panic
TestCheckOnceInvalidURL 非法 URL → 失败状态
TestFreshMonitorStatusIsEmpty 初始状态为空
用 httptest 提供真实 HTTP 输入,不 mock client、不碰 m.status。
## 变异验证(改回旧实现必须红)
coldcontext Insert 不补 ID → ★ Insert 没有给空 ID 的文档补 ID
network 5xx 判可达 → ★ 5xx 端点判可达
tracker 无变更也造 changeset → ★ 无文件变更却 HasChanges=true
全部还原后复跑全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 17 个测试包 / 100 包无测试 / 0 FAIL
3 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 12:56:13 +08:00 |
|
|
|
443110506c
|
test: 按模块导出接口重写黑盒判据(批次 3:memory / agent-core)
延续批次 1/2 的形态:只调模块**本来就有**的导出接口,输入 → 可观察输出。
2 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 17 条判据。
## internal/memory(10 条)
纯函数(输入文本 → 输出):
TestCleanText 空白/「,」前缀(实现剥两次)
TestChannelScene "qq" → "chan:qq",空 → 空
TestFilterNoiseTriples 噪音主语/宾语被过滤,且不改输入切片
TestSentenceBlockIDIsStable 同文本同 ID
TestNewSentenceBlockFields Trim 后的 Text 与 ID
TestBuildCentroid 中心向量均值 = 输入算术均值
GraphDB 真实构造 → 写入 → 可观察输出:
TestCommitThenGraphData Commit → GraphData 给出 nodes 结构
TestReadOnlyHandleRejectsWrite OpenGraphDBReadOnly 在 SQLite 层拒写
`TestReadOnlyHandleRejectsWrite` 守的是「子 agent 改不了主记忆」的
**结构性**保证(PRAGMA query_only=1),不是靠调用方自觉。
**判据写错过两次,按真实契约修正**:
① `CleanText` 连剥两次「,」,第一版按"只剥一次"写 ⇒ 红。
② 只读句柄的第一版三元组用了单字符实体名,被 `validEntityName`
提前拒掉、根本走不到 SQL 层 ⇒ 假绿。换成合法实体名后才真正验到
query_only。这个坑值得记:**判据的输入必须能走到被测路径**。
## internal/agent/core(7 条)
TestAgentID New(AgentConfig{ID}) → ID()
TestOutputAllowlist 空白名单=完整授权;非空=白名单
TestDuplicateInputWindow 同源同内容第二次判重;异源/异内容否
TestRealtimeFlag 普通 agent=false;nil receiver 安全
TestEmptyCollectionsOnFreshAgent Residents/RTChildren/DumpScheduler 为空
TestResolveOutputTargetWithoutRegistry 无注册表 → ok=false,不 panic
TestGetKernelStatusNeverNil 状态查询返回可用对象
只用 `New(AgentConfig)` 装配,不启动事件循环(已核实 `New` 里没有
`go a.xxx` / `Start()`),不构造 TaskFrame、不调 stepLLM/runStage、
不碰 a.sched、不读源码。
## 变异验证(改回旧实现必须红)
memory CleanText 不剥前缀 → ★ CleanText(",,你好")=",你好"
memory 只读句柄不设 query_only → ★ 只读句柄接受了写入
core 白名单恒放行 → ★ 白名单外的通道被放行
core 去重恒 false → ★ 同源同内容第二次未判重
全部还原后复跑全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 14 个测试包 / 100 包无测试 / 0 FAIL
2 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 12:37:52 +08:00 |
|
|
|
8dff678640
|
test: 按模块导出接口重写黑盒判据(批次 2:security/events/safeclose/kstate)
延续批次 1 的形态:只调模块**本来就有**的导出接口,输入 → 可观察输出。
4 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 24 条判据,每模块 1 个文件。
## 各模块的接口清单与判据
### internal/security —— NewCodec / Seal / Open / Domain / Enabled
TestCodecSealOpenRoundTrip Seal → Open 得回原文;Seal 不返回原文
TestCodecDomainSeparation 不同 domain 的密文互不通用
TestCodecWrongKeyCannotOpen 换钥后旧密文不可读
TestCodecEnabledAndDomain 元信息输出;nil Codec 不报 Enabled
TestCodecOldKeysStillDecrypt NewCodecWithOldKeys 轮换后旧密文可读
TestCodecOpenLegacyPlaintextPassthrough 旧明文原样透传
TestCodecOpenRejectsCorruptedCiphertext 带密文前缀但内容坏掉必须报错
**判据写错过一次并修正**:第一版把 `Open("not-a-cipher")` 也要求报错,
实测红。查实现(codec.go:138)确认那是**明确的兼容契约** —— 加密是后来加的,
库里存在未加密的历史值,判成错误会让整库读不出来。改成两条:
旧明文透传 / 损坏密文报错。把差异显式写下来。
### internal/events —— NewBus / Subscribe / Publish / SetGuard
TestPublishDeliversPayload 订阅后发布,handler 收到原 Payload
TestUnsubscribeStopsDelivery 退订后不再收到
TestOnlyMatchingTypeReceives 不同事件类型不串台
TestPublisherIdentity 空 Name = 内核,非空 = 插件
TestSetGuardRoundTrip SetGuard → Guard 拿回同一守卫
`Guard` stub 按真实接口签名实现(AllowPublish 返回 `(bool, string)`、
Scrub* 返回 map),不是猜的。
### internal/safeclose —— Safe / SafeFunc
TestSafeFuncIsIdempotent 连调 5 次不 panic,channel 确已关闭
TestSafeReturnsFiredOnce 同一把 Once 只有第一次返回 true
TestSafeRejectsNilInputs nil once / nil chan 安全返回 false
TestConcurrentCloseIsSafe 8 goroutine 并发关同一 channel
不用任何 `NewOnceForTest` 之类的测试钩子 —— `sync.Once` 由调用方自己造,
这正是模块本来的公开用法。
### internal/kstate —— NewSyncBus / Mount / Get / State
TestSyncBusReadyLifecycle MarkDeclared/MarkReady → IsReady/AllReady/Missing
TestSyncBusReadyInOrderIsStable 就绪顺序可复现,重复标记不重复记录
TestSyncBusKnownVsReady 声明过 ≠ 已就绪
TestSyncBusKernelComplete 内核完成标记可观察
TestSyncBusSnapshotIsCopy 外部改快照不影响总线
TestMountGet Mount → Get 拿到同一份状态
TestStateCloseAllInvokesClosers AddCloser 注册的清理被调用
不用 `MountForTest`(上一轮已删除):直接用 `Mount` + `Get` 断言输出。
## 变异验证(改回旧实现必须红)
security Seal 直接返回原文 → ★ Seal 返回了原文 —— 没有加密
events 退订函数变空操作 → ★ 退订后仍收到 1 次
kstate MarkReady 不记 order → ★ ReadyInOrder = [],期望 [a b]
safeclose Safe 不用 sync.Once → panic: close of closed channel
前两条第一版变异因文本形态不匹配**没有变红**,重做后确认判据有效 ——
空变异不算变异验证,这一点按纪律写清楚。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 13 个测试包 / 100 包无测试 / 0 FAIL
4 个新包 -race 全绿
源码目录 _test.go 0
|
2026-11-09 12:21:13 +08:00 |
|
|
|
3384cee62a
|
test: 按模块导出接口重写黑盒判据(批次 1:config/knowledge/distill/agent-api)
## 方向
「面向模块输入接受模块输出」:只调模块**本来就有**的导出接口,
输入 → 断言可观察输出。不从旧白盒里抽、不读源码、不引用文档、
不构造内部结构、不为测试导出任何新符号。
4 个文件全部放 `<module>/test/`,源码目录仍零 `_test.go`。
共 23 条判据,每模块 1 个文件。
## 各模块的接口清单与判据
### internal/config —— NewConfigRegistry / PluginConfig
TestCoreSetGetDelete Set → Get → Delete → Get=nil
TestPluginNamespaceIsIsolated 两插件同名 key 不串台;核心表看不到插件键
TestPluginListByPrefix List 只返回匹配前缀的键
TestDefRoundTripPreservesSecret RegisterDef → ListDefs 保住 Secret
TestSecretValueIsNotPlaintextOnDisk Set → GetSecret 往返 + DB 里无明文
**判据按真实契约写**:插件表由 `RegisterDef` 创建,`Set` 不隐式建表。
第一版没先 RegisterDef,实测 `no such table: config_alpha` —— 不是缺陷,
是我把装配顺序当成了实现细节。判据里显式写了这条契约。
### internal/knowledge —— NewStore(root) / Add / List / Stats / Remove / Search / Categories
TestAddThenListAndStats Add → List/Stats
TestRemoveActuallyRemoves Remove → 空;二次 Remove 报错
TestPathTraversalIsRejected ".." / "." / 空名 必须被拒
TestCategoriesIncludeIntermediateLevels tech/go/叶名 ⇒ tech、tech/go 都出现
TestSearchRanksRelevantAboveUnrelated 只问排序,不钉数值
`TestSearchRanksRelatedAboveUnrelated` 直接对治 engineering-lessons §1.1
那个坑:16 条判据全绿而检索结果完全无关(分数全挤在 0.75~0.79)。
**判据写错过一次并修正**:`Categories()` 返回的是**父路径**,
`tech/go/concurrency` 条目的 Category 是 `tech/go`,不含叶名。
第一版断言叶名也在列表里 ⇒ 红。查实现后按真实契约改成两层。
### internal/memory/distill —— 纯函数,输入输出就是全部语义
TestBlockTextRoundTrip ToMemoryBlock → BlockSubject/Dimension/Value
TestBlockIDDependsOnSubject 不同主语不得撞 ID(静默数据丢失)
TestBlockIDIsDeterministic 同输入同输出
TestDimensionValueEdgeCases 无 '=' / 空维度 / 多 '='
TestSentenceBlockIDIsStable 原句块 ID 稳定
TestNormalizeDimensionIsIdempotent 归一幂等
### internal/agent/api —— 纯函数 + ProviderManager 公开状态机
TestEstimateTokensIsMonotonic 更长文本不得更少 token
TestTruncateByTokensRespectsBudget 截断后不超预算
TestModelContextWindowAlwaysPositive
TestIsValidSourceConfig 四字段 + URL scheme 真值表
TestRegisterGetListDefault 注册顺序/按名取回/默认源切换
TestAvailabilityStateMachine MarkUnavailable → 不可用 → Reset 恢复
TestProviderErrorCarriesStatusAndMessage
`ProviderManager` 用的是真 `NewProviderManager()` + 满足 `Provider`
接口的 stub,不碰 `m.status`、不用任何 ForTest 钩子。
## 变异验证(每条至少一次,改回旧实现必须红)
config ListDefs 丢 Secret → ★ Secret:true 在 RegisterDef → ListDefs 后丢失
knowledge normalizeName 放行 ".." → ★ Add("..") 被接受了 —— 路径可逃出知识根
distill BlockID 丢 subject → ★ 不同主语得到同一个 BlockID
api 漏 URL scheme 校验 → ★ ftp://x 判成了 true
全部还原后复跑全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 9 个测试包 / 100 个包无测试 / 0 FAIL
4 个新包 -race 全绿
lens 4 个新文件 LSP 0 诊断
|
2026-11-09 11:57:48 +08:00 |
|
|
|
53cdc7f2a6
|
refactor(test): 肃清全部白盒测试 —— 源码目录零 _test.go,判据只走模块导出接口
## 用户裁定(本次唯一方向)
1. 「测试不应该面向过程而是面向模块的输入和输出」
2. 「每个模块下应该使用 test 目录搜集所有测试文件,而不是和源码混在一起」
3. 「现在 test 都超过源码数量了,这很明显不符合模块接口黑盒化的要求」
4. 「ast判包依赖?这不是工具链该做的吗?为什么在测试里」
5. 「抽行为判据?只需要面向模块输入接受模块输出就行」
6. 「不管ci,直接把所有白盒测试全清理了再回去一鼓作气改ci」
## 实测起点
537 个测试文件 / 377 个源码文件 / 2853 条 Test
523 / 537(97%)直接调用私有函数
514 个是 in-package 白盒(package xxx,与源码同目录)
88 个读 .go/.md 源码做断言
94 个断言私有字段(f.Step / r.buf / a.sched …)
38 个直调私有函数(stepLLM / runStage / classifyProviderError …)
internal/agent/core: 140 test / 52 src
## 做了什么
### ① 删除 514 个 in-package 白盒测试
不做「抽行为判据」,也不做逐文件搬运。理由:Go 按目录分包,
`pkg/test/` 里写 `package pkg` 也看不到 unexported 符号 —— 所以
「挪进 test/」和「改成黑盒」是同一件事的两面。把 514 个白盒文件
搬走只会得到一堆编译不过的文件,或者为了迁就它们把内部全导出。
用户明确否定「抽」这个动作后,剩下 9 个 test/ 里的判据也一并
重新审视:凡是靠读源码/前端资源文本做断言的(dashboardJSSource /
FrontendAsset / GraphDataForVisual / SSEEventRing …)全部删除,
只留走模块**本来就有**的公开 API 的输入→输出判据:
internal/agent/core/child/test/ Registry 的 Add/Get/Stop/Remove 输出
internal/agent/core/test/ StageHost 的 Register/Run 输出
internal/plugins/files/test/ 工具 handler 的 {path} → {content/error}
internal/plugins/webui/test/ HTTP 请求 → 响应(NewHandler/RegisterRoutes)
internal/tfidf/test/ 文本 → 向量/检索结果
现存 9 个测试文件全部在 `<module>/test/`,源码目录零 `_test.go`。
### ② 回退「为测试而导出」的接口
这些是前几轮为了把白盒判据挪出源码目录而导出的,属于白盒残留,
现在没有调用方了,全部收回私有:
webui.FrontendAsset / DashboardPage 删除
webui.GraphDataForVisual → graphDataForVisual
webui.SSEEventRing/Record → sseEventRing/sseEventRecord(含字段)
webui.NewSSEEventRing 删除
kstate.MountForTest 删除
security.ClassifyPrincipalForTest 删除
生产代码对它们的内部调用同步改回私有名,不改任何行为。
### ③ CI 一鼓作气改到位(用户指定顺序)
**删**:`整机启动图判据` 单列步骤(它跑的 bootgraph_test.go 已删),
以及 `check-ci-gates.py` 里盯着那个步骤的断言 —— 继续盯着一个
不存在的步骤就是永远红的空转。
**加两道构建期静态门**(原先这些约束错放在 go test 里):
- `测试归拢(源码目录不得有 _test.go)`
`find internal cmd pkg providers deploy -name '*_test.go' -not -path '*/test/*'`
非空即红。物理上挡住白盒回潮。
- `包依赖门(kstate 为叶子 / 底层包不反向依赖装配器)`
用 `go list -deps` 判 8 组禁止依赖 + `go build core` 确认环不存在。
这是**工具链**的职责,不该由 `go test` 兼职。
`check-ci-gates.py` 同步改为盯这两道门;ci.yml 里 13 处既有
>80 字符行一并折行(门禁标了 STOP,不留尾巴)。
### ④ 顺带(上一批未提交的)
`scripts/make-package.sh` 产物名 `HomeAgent-linux-*` →
`HologramAgent-linux-*`;清理 gitignored 构建产物
`build/ dist/ .go/ .npm-cache/` 共 20G(工作区 21G → 1.7G)。
运行契约(~/.homeagent/cli.sock、线上域名、兼容别名)未动。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 5 个测试包全过 / 100 个包无测试 / 0 FAIL
源码目录 _test.go 数 0
测试文件数 9(全部在 <module>/test/)
## 后续
按模块导出接口清单重写判据(不是从旧白盒里抽):
internal/agent/api、internal/memory、internal/security、internal/plugin、
internal/knowledge、cmd/* 等目前 0 判据,需要逐个列接口 → 写输入→输出判据
→ 变异验证。这是新一轮工作,本提交不含。
|
2026-11-09 11:13:56 +08:00 |
|
|
|
790a28fdfd
|
fix(webui): 补提交 kernel_buttons 判据的 package/import 改动
## 事故:上一个 commit 只提交了 rename,没提交内容改动
`fb67703` 用 `git mv` 把 `kernel_buttons_render_test.go` 移进 `test/`,
但改 `package webui → package test`、`dashboardHTML → webui.DashboardPage()`
这些编辑留在工作区没进暂存区。结果 **HEAD 的 `test/` 目录里放着一个
`package webui` 的文件**:
found packages test (chat_ack_timeout_test.go) and webui
(kernel_buttons_render_test.go) in internal/plugins/webui/test
本仓 `.gitignore` 忽略 `build/`,本地 `go test ./...` 也过了 —— 但那是
**带未提交改动的工作区**过了,不是这个 commit 过了。干净 worktree 复验:
git worktree add /tmp/cleanchk HEAD
go test ./internal/plugins/webui/test/
→ FAIL [setup failed] found packages test and webui
这正是 2026-11-03 定的提交纪律第 5 条要拦的事。上一批做完 `git mv`
只 `git diff --cached --stat` 看到 "0 insertions" 就该警觉 —— rename 的
staged diff 是 0,而内容改动是 unstaged,等于提交了个半成品。
## 教训(写进流程)
`git mv` 之后**必须再 `git add` 一次同名路径**,否则只提交移动本身。
以后每批提交前一律:
git diff --stat # 看 unstaged 是否还有内容
git worktree add /tmp/clean <sha> # 干净检出复验
go build ./... && go test ./...
## 本提交的改动
package webui → package test
dashboardHTML → webui.DashboardPage()
FrontendAsset → webui.FrontendAsset
import 补 internal/plugins/webui + gofmt 排序
## 验证
干净 worktree(本提交后)复验通过,见下一条命令的输出。
|
2026-11-09 09:58:14 +08:00 |
|
|
|
fb6770350d
|
refactor(webui): 内核按钮判据迁到 test/ —— 走导出的页面与资源入口
kernel_buttons_render_test.go → test/ (3 条判据)
webui/ 161 → 158
webui/test/ 41 → 44
合计 202 不变
这条最省事:判据本来就断言"组装后发给浏览器的字节里有什么",
只差两个入口没导出。上一批已经导出了 `FrontendAsset` 与
`DashboardPage`,所以只改调用点:
dashboardHTML → webui.DashboardPage()
FrontendAsset("x.js") → webui.FrontendAsset("x.js")
它守的两件事仍然有效(写在判据头部注释里):
1. 按钮写进了 dashboard.html,但 {{DASHBOARD_JS}} 占位符没被替换/替换错位
⇒ 页面里看不到按钮
2. JS 函数被包在 IIFE 里不在全局,onclick="kernelRestart()" 点了报
"kernelRestart is not defined"
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 67 包全过,0 FAIL
|
2026-11-08 09:50:11 +08:00 |
|
|
|
1d38353498
|
refactor(webui): SSE 事件环判据迁到 test/ —— 导出环类型与记录字段
handler_sse_test.go → test/ (6 条判据)
webui/ 167 → 161
webui/test/ 35 → 41
合计 202 不变
## 原来那 6 条判据读的是私有字段
len(r.buf)
r.buf[0].ID / .EventType / .Data
`sseEventRing` 的对外语义就三条:容量上限、按 Last-Event-ID 重放、
未知 ID 回退全量。它们全部可以从**导出接口**断言:
NewSSEEventRing(cap) 构造
Append(id, type, data) 写入
After(id) []SSEEventRecord 重放
所以导出 `sseEventRing → SSEEventRing`、`SSEEventRecord` 的字段、
以及构造入口 `NewSSEEventRing`。
**为什么这不是"为测试导出内部"**:`After` 返回的东西就是客户端断线
重连时实际收到的内容 —— id、event、payload 三样。它们是外部可见的输出,
把字段藏起来只让包内测试能读,才是反过来的怪事。
测试侧的 `r.buf[i]` 统一换成 `r.After("")[i]`(空 ID 命中不了,按约定
回退全量),语义等价且不再触碰内部状态。
## 判据有效性(变异验证)
把容量裁剪短路掉(`if false && len(r.buf) > r.cap`):
--- FAIL: TestSSEEventRing_Append_Capacity
expected 3, got 5
环不再丢最旧条目 ⇒ 断线重连会重放超过窗口的历史,客户端 Last-Event-ID
语义失效。恢复后全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 67 包全过,0 FAIL
|
2026-11-04 22:21:54 +08:00 |
|
|
|
bb29096f60
|
refactor(webui): 图谱判据迁到 test/ —— 导出 GraphDataForVisual 一层转换
graph_source_test.go → test/
handler_graph_pulse_test.go → test/ (7 条判据)
webui/ 174 → 167
webui/test/ 28 → 35
合计 202 不变
## 这一条必须动生产代码,理由要写清
判据想测的是「GraphData 的 raw map 被裁成可视化视图时,稠密向量必须被剥掉」。
两条路都不通:
走 HTTP 端点 /api/v1/memory/graph
→ 端点输入来自 h.memory.GraphData(),h.memory 是私有字段,
包外无法注入合成数据。测不了。
包内测试直接调 graphDataForVisual
→ 这正是要摆脱的白盒写法。
所以导出这一层转换 `graphDataForVisual → GraphDataForVisual`。它不是
为测试造出来的形状:函数的输入输出本来就是本包唯一的「raw map →
visual map」语义,端点在 handler_memory.go:199 调它。导出的是真实边界,
不是内部细节。(判据 §5.3「不要为满足测试导出内部」指的是后者。)
顺带把 `graphBlockView`(私有 JSON 结构)在测试侧改成匿名结构体 ——
它只用于反序列化断言,没有必要引用包内类型。
## 改走真实路由暴露的两个事实
### ① `newGraphHandler` 直接设 `&Handler{memory: m}`
改成 `NewHandler(sdk.New("webui", sdk.SDKConfig{Memory: m, ...}))` ——
`Memory` 确实能经 SDKConfig 注入(plugin.go:457 `base.SetMemoryAPI(cfg.Memory)`)。
### ② 503 暴露了鉴权要求
改走路由后立刻:
status = 503, want 200
`requireAPI` 需要 api_key,而最小装配没配。补 `seedWebUIConfig` 后 200。
之前直接调 handler 的写法**从来不知道有这个要求** —— 路由层挡掉的东西,
绕过路由就看不见。
## 判据有效性(变异验证)
在 `GraphDataForVisual` 里把裁剪短路掉(`if blocks != nil { return out }`),
实测红,且输出直接暴露泄漏内容:
--- FAIL: TestHandleMemoryGraph_StripsVector
response still contains vector field:
{"data":{"memory_blocks":[{"id":"b_old",...,"vector":[0.1,0.2,0.3],...
"success":true}
稠密向量确实会被下发到前端。恢复后全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 67 包全过,0 FAIL
|
2026-11-04 22:06:35 +08:00 |
|
|
|
2e90c2cb5a
|
refactor(webui): openai_compat 判据迁到 test/,包内重建共享辅助
openai_compat_test.go → webui/test/ (4 条判据)
webui/ 178 → 174 条
webui/test/ 24 → 28 条
合计 202 条不变
## 迁移碰到的连带问题
`openai_compat_test.go` 里除了自己的判据,还定义了三个**被包内其他文件
依赖**的共享件:
newOpenAITestServer(t) auth_hardening_test.go 在用
testAuthAPIKey auth_hardening / gzip / legacy_proxy(×2) 在用
seedWebUIConfig 多处
文件一移走,包内立刻 build failed:
vet: auth_hardening_test.go:373:15: undefined: newOpenAITestServer
vet: auth_hardening_test.go:377:30: undefined: testAuthAPIKey
这说明「迁一个测试文件」从来不是孤立的 —— 它可能是包内共享辅助的**宿主**。
## 处理方式:两边各留一份,不跨包共享
包内重定义 `testAuthAPIKey` 与 `newOpenAITestServer`(放 handler_test.go),
test/ 侧保留自己那份(`seedWebUIConfig` 统一收进 helpers_test.go)。
**为什么不抽到共享包**:包内版本要用包内的 `testSDK`/`seedWebUIConfig`,
test/ 版本要用那边的 helper。抽公共包等于让 test/ 反向依赖包内的未导出
辅助 —— 那正是这次迁移要摆脱的东西。两份各写 20 行,换来两侧互不牵连,
比一个"共享测试工具包"更符合这次的目的。
## 顺带修掉一处重复定义
`seedWebUIConfig` 在 secret_meta_test.go 与 helpers_test.go 各出现过一次
(上一批我加的那份没和已有的对齐)。收拢到 helpers_test.go 一份,
字段与包内版本保持一致(api_key/username/password/session_ttl_hours)。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 67 包全过,0 FAIL
webui/ + webui/test/ 174 + 28 = 202 条,与迁移前一致
|
2026-11-04 21:52:52 +08:00 |
|
|
|
b3ae99e32f
|
refactor(webui): secret meta 判据迁到 test/ —— 走导出路由而非未导出 handler
## 迁移
secret_meta_test.go → webui/test/
## 这一条迁移暴露的问题比前几次多
原测试用两种白盒写法:
h := &Handler{settings: ...} // 直接构造,设私有字段
h.handleSettings(w, req) // 直接调未导出 handler
h.handleSettingsReveal(...) // 同上
改成黑箱后依次撞到四件事,逐个解决:
### ① 路由是 `/api/v1/settings`,不是 `/api/settings`
原测试写的路径 `/api/settings` 从来没被路由表匹配过 —— 它直接调 handler,
所以路径是摆设。真实注册在 handler.go:551:
mux.HandleFunc("/api/v1/settings", h.requireAPI(h.handleSettings))
⇒ 走真实路由后必须用对路径,否则 404。
### ② `Handler()` 会套三层中间件,依赖完整装配
`Handler()` = proxyDispatch + gzipMW + logged + mux。其中 `logged` 读
`h.settings` 的 webui 配置、`requestLog` 读 history store 等 ——
这些在最小装配下是 nil,直接 panic。
这不是"中间件写坏了",是本判据不该测它们。**用导出的 `RegisterRoutes`**:
mux := http.NewServeMux()
h.RegisterRoutes(mux) // 只注册业务路由,不套中间件
`RegisterRoutes` 是导出接口,拿到的是真实路由表 —— 仍是黑箱,
只是不带外层。判据测的是 settings 的 meta 与 reveal,不该被请求日志和
反代的依赖拖下水。
### ③ `requireAPI` 需要 api_key,没配就 503
{"error":"webui api_key not configured"}
补 `seedWebUIConfig`(与包内同名辅助一致),并在请求上带
`X-API-Key`。这是生产上非浏览器客户端的正常路径
(`validAPIKey` 优先于 cookie session)。
### ④ `SecretPlaceholder` 需导出访问
改成 `webui.SecretPlaceholder`。
## 判据有效性(变异验证)
secret 标记的传递链是
`ConfigDef.Secret → mapDefs → sdk.ConfigDef.Secret → HTTP meta`。
在 `internal/sdk/settings_impl.go` 的 mapDefs 里删掉 `Secret: d.Secret`:
--- FAIL: TestSecretFlagReachesSettingsMeta
Secret:true 未传递到 meta(前端会明文回显):
map[default: display_name:标志式 key:by_flag type:string]
恢复后全绿。这条判据守的是真实缺陷形态:SDK 的 ConfigDef 一直有 Secret
字段,但内核侧三处转换都漏过它,导致敏感项被渲染成普通文本框明文回显。
## 进度
webui/ 178 条(迁走 2 条)
webui/test/ 24 条
`webui/` 剩 19 个测试文件。剩下的大头是 `proxy_test.go`(45 处未导出引用)
与 `handler_test.go`(90 处),它们直接调 `handleXxx` 并依赖
`newTestHandler`/`testSDK` 这套装配辅助 —— 迁移需要先把那套辅助搬到
test/ 共享,再逐个改调用点。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 67 包全过,0 FAIL
|
2026-11-04 21:34:17 +08:00 |
|
|
|
c01f4d6350
|
test: 两个零测试包补黑箱判据 —— files(沙箱)与 tfidf
覆盖率分析显示这两个包**零测试文件**,而它们各自守着一条不能错的边界:
internal/plugins/files 581 行 含沙箱逃逸防护
internal/tfidf 306 行 记忆检索的 fallback 路径
## files:沙箱边界
581 行、0 判据。它是文件读写插件,`pathWithinSandbox` 写错一个字符就是
任意文件读写。没有判据意味着没人知道它现在是否还挡得住。
6 条判据全部走**真实装配**(调 `Start()` 而不是手搓 Plugin 结构),
通过注入的 registrar 捕获 handler,然后以「输入 {path, content} → 输出」断言:
TestToolsAreRegistered 四个工具都在
TestReadWriteRoundTrip 写进去能原样读回
TestSandboxEscapeIsRejected ★ 沙箱外路径必须被拒(3 种逃逸形态)
TestSandboxAllowsInside 反向:沙箱内必须能读
TestEmptyPathIsRejected read/write 拒绝空 path
TestLsEmptyPathMeansCurrentDir ls 把空 path 当 "."
TestLsListsDirectory ls 列出目录内容
**沙箱那条的判据设计**:沙箱外造一个**真实存在**的文件。若只拦不存在的
路径,判据会假绿;文件真实存在而仍被拒,才证明是沙箱在起作用。
变异(`pathWithinSandbox` 加 `if true { return true }`)⇒ 实测红:
★ 沙箱外路径 ".../secret.txt" 被接受了,返回 {"content":"SECRET",...}
这是任意文件读取。沙箱根: ...
## tfidf:检索判别力
306 行、0 判据。它是没有神经网络 embedder 时的文本相似度来源 ——
判据错 = 检索错,而检索错是静默的(不报错,只是答非所问)。
判据**只断言契约,不断言数值**:TF-IDF 的权重取决于语料统计,写死数值
等于把训练集也当成契约。6 条钉的是换了实现也必须成立的性质:
TestVectorizeIsDeterministic 同输入同输出(map 遍历浮点累加
会产生末位差异,让缓存/去重失效)
TestVectorizeHasFixedDimAndNonNegative 维度恒定、非 NaN/Inf、非负
TestEmptyTextGivesZeroVector 空文本不 panic 且给零向量
TestSearchRanksRelatedAboveUnrelated ★ 核心质量判据:只排序,不看数值
TestAddRemoveChangesResults 增删后检索结果随之变化
TestFingerprintAndDim 元信息非空
`TestSearchRanksRelatedAboveUnrelated` 直接对治 engineering-lessons §1.1
记的那个坑:16 条判据全绿而检索结果完全无关(分数全挤在 0.75~0.79)。
所以它只问"billing invoice 的查询里,auth 文档有没有排到相关文档前面"。
变异(`Search` 提前 `return nil`)⇒ 实测红两条。
## 一处判据写错了(已按实际契约修正)
第一版把 `files_ls` 也纳入"空 path 必须拒绝",实测红:
★ files_ls 接受了空 path,返回 {"content":"(empty directory)"}
查代码(plugin.go:488)发现那是**明确设计**:`if path == "" { path = "." }`。
不是缺陷,是我把三条工具的输入契约当成同一条了。改成两条判据分开钉:
TestEmptyPathIsRejected read/write 拒绝空 path
TestLsEmptyPathMeansCurrentDir ls 把空 path 当 "."
把差异显式写下来,以后有人想"统一都拒绝空 path"时能看到这是有意的。
## 覆盖率
internal/plugins/files 0.0% → 54.1%
internal/tfidf 0.0% → 有判据(契约全覆盖)
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 67 包全过,0 FAIL(新增 2 个 test 包)
|
2026-11-04 20:41:43 +08:00 |
|
|
|
b8ec1e84d2
|
refactor(webui): 再迁 3 个判据到 test/ —— 去重辅助函数
## 迁移
dashboard_js_scope_test.go → webui/test/
embed_read_test.go → webui/test/
mascot_asset_test.go → webui/test/
这三个原先都直接读未导出的 `dashboardFS`(`go:embed` 的变量)。
上一批已经加了导出入口 `FrontendAsset(name)`,所以只需换调用即可,
不涉及行为改动。
## 迁移暴露的三处重复(一并去重)
迁过去之后编译报了三组重复定义:
stripJSComments helpers_test.go 与 dashboard_js_scope_test.go 各一份
dashboardJSSource helpers_test.go 与 dashboard_js_scope_test.go 各一份
mustReadEmbedded embed_read_test.go 里定义,kernel_buttons_render_test.go 在用
前两个是**迁移造成的镜像**:原本包内的辅助函数在迁到 test/ 时被复制了一份。
逐字比较确认两份 `stripJSComments` 实现完全相同,删掉包内那份、保留
`test/helpers_test.go` 里的共享版本。
第三个不同:`mustReadEmbedded` 只有包内的 `kernel_buttons_render_test.go` 在用。
把它改成直接调导出的 `FrontendAsset`,不再需要这个辅助函数 ——
包内测试从此也不再依赖"哪个变量装着 embed 资源"这种实现细节。
## 顺带清掉一批未使用导入
批量迁移+脚本改包名之后,多个文件留着已经不用的 `webui` 导入
(有的只在注释里提到 `webui.FrontendAsset`)。用 `go vet` 的输出逐个清理,
最后 `go vet ./internal/plugins/webui/...` 干净。
## 判据未失效(变异验证)
把 dashboard.js 里 ack 超时 `}, 600000);` 改成 `}, 30000);`:
--- FAIL: TestChatAckTimeoutIsGenerous
chat_ack_timeout_test.go:59: ★ 受理超时只有 30000 ms —— 太小。
恢复后全绿。
## 进度
webui/ 180 条(原 186,迁走 6 条)
webui/test/ 22 条
合计 202 条,与迁移前一致(+ 之前新增的契约判据)
`webui/` 剩下 20 个测试文件,全部依赖 `handleXxx` / `newTestHandler` /
`testSDK` 等未导出 handler 与装配辅助,需要先有导出入口才能迁。
其中 `proxy_test.go`(45 处)和 `handler_test.go`(90 处)是最大两块,
要迁必须先给 webui 导出"起一个带路由的 handler"的测试入口。
## 验证
go build ./... PASS
go vet ./internal/plugins/webui/... PASS
gofmt -l 干净
go test ./... -count=1 65 包全过,0 FAIL
webui 连跑 3 次 0 失败
|
2026-11-04 20:23:34 +08:00 |
|
|
|
892247946a
|
fix: staticcheck 24 条存量全部清零(含 4 个真实缺陷)
staticcheck 门接入时把存量设为基线 24 条,本次全部清掉,基线改为 0。
## 生产代码里的真实缺陷(4 个)
### ① memory/fusion.go —— BFS 的 depth 参数形同虚设
for d := 1; d <= depth; d++ {
neighbors, _ := g.NeighbourBlocks(seed.BlockID) // ← 永远是 seed
...
break // ← 只跑一轮
}
三处叠加:循环体末尾无条件 `break`;每层传的还是 `seed.BlockID`;
于是 depth=2 与 depth=3 召回结果**完全一样**。
staticcheck SA4008("变量在循环条件里从不改变")正是从这里报出来的 ——
它看出来 `d` 除了打分没有参与寻址。
改成真正的逐层 BFS(frontier 推进到上一层新发现的节点)。实测:
depth=1 → 2 个 [节点0 节点1]
depth=2 → 3 个 [节点0 节点1 节点2] ← 修复前停在这里
depth=3 → 4 个 [节点0 节点1 节点2 节点3]
补判据 `TestBFS召回_层深真实生效`:必须用**链**(n0-n1-n2-n3),
星型在 depth=1 就能召回全部叶子、断不出有没有走深。
变异(改回单层)⇒ 实测红:`★ depth=2 召回 2 个,期望 3`。
### ② nlp/extractor.go —— subjRels/objRels 只写不读
两个切片被 append 了 4 处,但**从未被读取**。三元组的关系类型来自
`relLabel := result.Tokens[vi]`,置信度来自 `result.DepRels[si]`,
与这两个切片无关。删除。
### ③ plugins/cmd/plugin.go —— 配置解析失败会把上限写成 0
if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 {
} // ← 空分支
直接 Sscanf 到 &p.maxOutput:解析失败时它可能已被写成 0 或部分值,
而空分支无法回滚 ⇒ 配置写错一个字符就把输出上限变成 0
(下游 `maxLen := p.maxOutput` 直接拿它算截断长度)。
改为解析到临时变量、失败时保持默认并记日志。
### ④ cmd/homed/bootstrap.go —— 空的 else 分支
`if err != nil { log } else { }` —— 删掉 else。
## 测试判据里的真实缺陷(3 个)
### ⑤ stop_semantics_test —— 短路求值让第二次调用从不执行
if !a.sched.consumeCancel() || !a.sched.consumeCancel()
第一次返回 true 时第二次**根本不会执行**,于是"两条配额都可消费"
这条断言实际只测了一次。拆成两次独立调用(两处同型)。
### ⑥ seq/store_test —— 永假断言
if maxCallDepth != maxCallDepth { t.Fatal("maxCallDepth 不稳定") }
自己跟自己比,从诞生起就不可能失败。改成显式读两次比较。
### ⑦ pluginhost/catalog_test —— typed-nil 守卫永假
var nb *kstate.SyncBus
var iface kstate.SyncState = nb
if iface == nil { t.Skip(...) } // ← 恒 false
把 typed-nil 装进接口后,接口的 (type, value) 二元组都不为 nil。
这道前置守卫**从未生效** —— 而这正是该判据要演示的陷阱本身。
改用 `reflect.ValueOf(iface).IsNil()`。
## 其余(写法/冗余,无行为变化)
agent.go / context.go / toolresult_store_test / apply_regression_test
空 if 块(注释移到真实分支上方)
codec_chunkfast_golden_test 未使用的三个返回值 + 空 if
scheduler_critical/stack_test `if _, _ = f(); true { }` → 直接调用
overflow_test / upgrade_test 变量被整体覆盖,首次赋值未使用
dense_test m[string(x)] 替代临时变量
lex_rules_test range over string 而非 []rune(string)
## 验证
staticcheck -checks 'SA4*,SA5*,SA6*,SA9*' 0 条(基线 24 → 0)
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 65 包全过,0 FAIL
干净检出(worktree) build + memory/nlp/core 全绿
|
2026-11-04 19:49:21 +08:00 |
|
|
|
8884cf9962
|
fix(io): 并发判据自带串话 —— 32 个 goroutine 抢 26 个 key
## 缺陷(既有,实测在本次改动之前的基线上同样复现)
`TestConsumeToolBlocksFor_ConcurrentNoCrossTalk` 约 1/8 概率假红:
.go:105: call E 期望 [block-4-E],实际 []
根因在判据**自己**:
const n = 32
id := string(rune('A' + i%26)) // ← 字母只有 26 个
i=0 与 i=26 都得到 'A',i=1 与 i=27 都得 'B' …… 共 6 个 id 各被两个
goroutine 共用。两者共享同一个 key:后 Set 覆盖先 Set 的、先 Consume
拿到空或别人的值。
所以它想验证"不同 key 不串话",却让多个 goroutine 抢同一个 key ——
**判据违反了它自己想验证的前提**。这不是 `SetToolBlocksFor` /
`ConsumeToolBlocksFor` 的并发缺陷(共用 key 本就该覆盖)。
## 修法
id := "call-" + strconv.Itoa(i) // 唯一
实测:修改前 12 次内命中 1 次;修改后连跑 20 次 0 失败。
## 补两条判据把前提本身钉住
TestConsumeToolBlocksFor_KeysAreIsolated
一次性写 64 个**互不相同**的 key,逐个读回必须各得其所。
不涉及并发 ⇒ 失败一定是真缺陷,不会偶发。
TestConsumeToolBlocksFor_ConsumeIsOneShot
读一次后清空(block 是一次性的)。
变异:删掉 `delete(m.toolPendingBlocks, callID)` ⇒ 实测红:
★ 二次消费应得空(block 是一次性的),实际 [b]
## 为什么这条值得单独修
一个 1/8 概率假红的判据,实际效果等于**没有判据**:
它在 CI 里红了几次之后,所有人都会学会"重跑一次"。
而这正是本仓"长红 ⇒ 整道门失效"教训的一个变体。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 65 包全过,0 FAIL
该判据连跑 20 次 0 失败(修改前 12 次中 1 次)
|
2026-11-04 19:24:22 +08:00 |
|
|
|
84021ef408
|
refactor(agent/core): 3 个黑箱判据迁到 core/test + 去掉一处白盒
## 迁移了什么
stages_test.go → internal/agent/core/test/
stages_plugin_test.go → internal/agent/core/test/
stages_tooldef_test.go → internal/agent/core/test/
包名 `core` → `test`。`agent/core` 目录少 3 个测试文件(该目录原有
196 个文件,其中 144 个是测试 —— 这是分步收敛的第一批)。
## 顺带去掉一处白盒
`stages_plugin_test.go` 原本这样断言"stage 表已清空":
h.mu.RLock()
_, exists := h.stages[sdk.StageAfterToolcall]
h.mu.RUnlock()
直接读**私有互斥锁和私有字段**。而 `UnregisterPluginStages` 的返回值
已经表达了同一件事。改为:
if n := h.UnregisterPluginStages("solo"); n != 1 { ... }
if n := h.UnregisterPluginStages("solo"); n != 0 { ... }
第二次摘除必须得 0 —— 如果内部表没清空,第二次仍会"摘到"残留计数。
这是黑箱可观测的等价断言。
变异验证:让 `UnregisterPluginStages` 假装摘除但不动表
(`if true { return 1 }`)⇒ 实测红:
--- FAIL: TestUnregisterPluginStages_RemovesOnlyThatPlugin
--- FAIL: TestUnregisterPluginStages_DropsEmptyStage
★ 重复摘除应得 0(说明内部已清空),实际 1
## 为什么只迁 3 个(agent/core 有 143 个测试文件)
先按「对源码未导出标识符的引用数」分档,实测:
0 引用 11 个文件
1 引用 约 20 个
... 其余逐步升高
但"0 引用"不等于"能迁" —— 逐个实测发现四种拦路虎:
① `newScrubRuntime` / `withScrubRuntime` / `newRTTestAgent` ——
包内测试辅助,其中 `withScrubRuntime(a *Agent, rt)` 直接设私有字段
② `computeTokenBudget` —— 未导出方法(tokenbudget_test 必须留包内)
③ `filepath.Glob("*.go")` —— 扫描"当前目录源码"的判据
(childkind_test),迁到子目录后会 glob 到空目录而**静默失效**
④ `newPreemptAgent` 等跨文件辅助
③ 尤其值得记:它不是编译错误,是**判据悄悄失去对象**。
## 踩到的坑(记下来避免重犯)
第一次批量迁 11 个文件、再用脚本自动加 `core.` 前缀,结果把
`import "…/internal/agent/core"` 本身也改了(变成 `core.core.core`),
还误伤了 `strconv.Itoa` 等标准库调用。批量文本替换在"加包名前缀"
这种场景下不可靠 —— 它会改到导入路径、注释、标准库符号。
最终改为**一次只迁一个、编译器逐步报错、手工修**。慢,但不会留下
半成品。agent/core 已完全还原过一次(git checkout HEAD)。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 64 包全过,0 FAIL
(core/test 是新包,63 → 64)
core/test 实跑用例数 17(确认不是空壳)
干净检出(worktree) build + core/... 全绿
## 后续
agent/core 剩余约 140 个测试文件,按上述四类拦路虎分批:
· 纯黑箱可迁的先迁
· 需要导出入口的,评估"导出是否合理"(如 FrontendAsset 那样)
· 真白盒的(改私有状态、测内部算法)加文件头说明为什么必须白盒
|
2026-11-04 18:26:46 +08:00 |
|
|
|
87f874e06f
|
refactor(webui): 7 个黑箱判据迁到 test/ + 导出前端资源入口
## 迁移了什么
chat_ack_timeout_test.go → test/
chat_attachment_img_test.go → test/
cmd_history_render_test.go → test/
config_namespace_test.go → test/
dashboard_tabs_deps_test.go → test/
plugin_status_render_test.go → test/
starmap_perf_test.go → test/
包名 `webui` → `test`。`webui/` 目录少 7 个测试文件。
## 为什么是这 7 个(分类依据)
先按「对未导出符号的依赖数」把 29 个测试文件测了一遍,这 7 个是
**0 依赖**(既不用 handleXxx,也不用 testSDK/newTestHandler 等测试辅助)。
其余 22 个按强度递增留在包内,后续逐步处理。
## 新增两个导出入口
### webui.FrontendAsset(name)
返回打包进二进制的前端资源原文(dashboard.js / dashboard.css /
mascot.webp / logo.svg …)。
判据要检查「实际会被发出去的那份文本」:dashboard.js 在严格模式下
会不会抛 ReferenceError(用 node 真跑)、mascot.webp 的魔数是否合法、
HTML 属性里有没有残留转义引号。
这些无法通过 HTTP 端点可靠断言(要登录态、且拿到的是拼接后整页,
定位不到单个 asset)。而留在包内测就得读 `dashboardFS` 这个**未导出变量**
—— 那正是"测试耦合实现"。导出后,判据变成「输入 asset 名 → 输出字节」,
与资源存在哪个变量、怎么 embed 无关。
### webui.DashboardPage()
返回组装完成的整页 HTML(外壳 + 内联 CSS/JS)—— 浏览器实际收到的那份。
与 FrontendAsset 的分工:那个取单个源资产,这个取成品。
## 顺带修掉迁移暴露的两个隐患
① 三个判据原本 `os.ReadFile("dashboard.js")` 读**磁盘相对路径**。
在包内恰好能跑(工作目录就是包目录),但那读的是**工作区文件**,
不是**二进制里实际嵌入的那份** —— 两者在生产上可能不同
(比如打包后 dashboard.js 被替换)。统一改走 FrontendAsset。
② starmap_perf_test 的相对路径 `../../../shared/starmap/layout.js`
与 `static/starmap-layout-worker.js` 在移入子目录后失效,已修正为
`../../../../shared/...` 与 `../static/...`。
## 判据仍有效(变异验证)
把 dashboard.js 里 ack 超时 `}, 600000);` 改成 `}, 30000);`:
--- FAIL: TestChatAckTimeoutIsGenerous
★ 受理超时只有 30000 ms —— 太小。
超时太短会让前端 abort,而 agent 仍在正常干活
恢复后全绿。迁移不是搬完就废。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
(webui/test 是新包,故 62 → 63)
干净检出(worktree) build + webui/... 全绿
## 后续
webui 剩余 22 个文件(依赖 handleXxx / testSDK 等),按强度递增处理
agent/core 739 处白盒引用,先分类再动
CI 补 coverage + staticcheck
|
2026-11-04 18:17:31 +08:00 |
|
|
|
7cef7df6f2
|
test: 判据改为面向接口输入输出 + 文档立规 + CI 补缺口
本提交是三件事的第一批,后续按计划分步推进。
## 一、判据改造(两条实证)
### 1. turn_hint 判据原是自证循环
原测试在测试里**手动重放**了真实注入逻辑:
f.Msgs = a.buildMessages(...)
hint := stageHint(f.StageCtx, "turn_hint")
f.Msgs = append(f.Msgs, Message{Role:"system", Content:"[Turn hints] "+hint})
// 然后断言"最后一条是 system、内容含 [Turn hints]"
它断言的是**它自己刚 append 的那一行** —— 把 task.go:656-661 抄进测试
再验证抄得对不对。真实代码那段删掉,它照样绿。
改为:把 turn_hint 放进 StageCtx,跑**真实的 stepPrepare**
(newTaskFrame 起始就是 StepPrepare),检查它产出的 Msgs。
变异验证:删掉 task.go 里 `if hint := stageHint(...)` 那段 ⇒ 实测红。
### 2. webui 文件下发判据原是白盒
原测试直接调未导出的 `stageWebFile(src, isImage)`、断言 `webFilesDir`
包级变量 —— 那都是**实现方式**。
改为从**输出通道 handler** 走(`RegisterOutputChannel("webui", …)`,
agent 发图/发文件时走的就是它):
输入:{payload: "<路径或URL>", type: "image"|"file"}
输出:{status:"ok", url:"/files/<name>.<ext>", size:N}
4 条判据(新版 handler_files_contract_test.go):
· 图片 → 可下载 URL(前缀/扩展名/size)
· 远端 URL 原样透传(不下载)
· 危险扩展名被中和(.html 保留会在 webui 域执行脚本)
· 空 payload 被拒
变异验证:丢扩展名 ⇒ 红;远端也拷贝 ⇒ 红。
## 二、文档立规:engineering-lessons 新增第五节
原来只有「判据会骗人」,没有「判据本身形状错了」。新增 6 小节:
5.1 判据必须把被测模块当黑箱(含"如果重写内部实现、接口不变,
这条判据还会绿吗"这个可操作判据)
5.2 禁止读源码做断言(附正确替代表)
5.2.1 判据不得以文档为依据
5.3 测试文件与业务代码分离(现状:521 文件 / 11.2 万行,
agent/core 一个包 196 文件里 144 个是测试)
5.4 CI 必须覆盖每个独立 module
5.5 CI 与本地必须跑同一套门
§5.2.1 的直接触发:`interrupt_invariant_test.go` 里有一条判据写着
「事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)」
—— 被引用方自己标着「状态:计划(未实施)」,而那条判据的实际断言
只是"入队后队列里 1 条",与声称的"反向依赖现状快照"毫无关系。
该判据已删除(入队行为已由 scheduler_preempt_test 的黑箱断言覆盖)。
## 三、CI 补两个缺口
### gofmt 门
提交纪律要求 `gofmt -l` 干净,但三个 workflow 里 `gofmt` 出现 **0 次**
—— 纪律只靠人自觉。补上后未格式化的代码进不了主干。
只查本仓自管目录:SDK 是独立仓,格式由它自己的 CI 管。
### SDK 判据步骤
`third_party/hologramagentsdk/` 有自己独立的 go.mod:
go list ./... | grep third_party → 0 个
主仓 `go test ./...` **永远不包含** SDK 任何测试。实测 SDK 有 30 个判据
文件,主仓 CI 一次都没跑过。
补的步骤只跑主仓 git 跟踪的文件(CI 上只有 22 个 vendored 文件,
工作机上是完整独立仓)—— 实测 `git archive` 出 22 个文件后
`go test ./sdk/ ./meta/` 全绿。
## 四、SDK 仓测试迁移(独立仓,已单独提交 3a38ef5)
`sdk/{plugin,proxy,stress}_test.go` → `sdk/test/`,包名 `sdk` → `test`。
关键:原测试直接写 `&PluginSDK{regStage: X, name: "test"}` —— **私有字段**。
而 `New(name, sett, regTool, regStage, regAPI, regOutput)` 本来就把所有
registrar 作为参数,等价且是生产路径。共改 19 处,0 处剩余。
主仓同步更新了跟踪路径(这三个文件主仓也 vendor 了一份)。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + 改造后的判据全绿
## 后续(按计划分步)
步骤 2 webui 29 个测试分类迁移(已实测出精确分类)
步骤 3 agent/core 739 处白盒引用先分类再动
步骤 4 SDK tools/hmapdev(package main,无法迁出,只加说明)
步骤 5 CI 补 coverage + staticcheck
步骤 6 文档双语(需定术语口径)
|
2026-11-04 18:11:06 +08:00 |
|
|
|
07d35297a6
|
fix(webui): 反代三处缺陷 —— 入口不显示、子域链接失效、前缀重定向逃逸
全部来自本机 systemd 实例的实测,不是推测。
1. 插件页「服务入口」永远显示"暂无"
TABS.plugins.fetch 漏了 proxyServices,而 renderPlugins 第一行就调
renderProxyServicesCard()(读 state.proxyServices)。于是该页签
永远不请求 /api/v1/proxy/services —— journalctl 实测:用户正常使用
期间零请求,只有我手工 curl 的那一条。
判据 TestProxyServicesReachesPluginsTab 钉住三环(A 调用 B、B 读
state、C fetch 含块名),已做变异验证(删掉该项立刻红)。
不用静态调用图做通用依赖检查:实测它在 dashboard.js 上不可靠
(中文注释引号破坏配平、分发器展开导致大量误报),已在注释里
写明原因,避免后人重复踩。
2. 面板生成的子域链接 100% 进不了反代
生成链接走 base_url 的主机名,路由匹配只认 base_domain。
生产实例 base_url=homeagent.jianfgit.xyz、base_domain=localhost,
于是链接落到门户 mux → 302 /login,而手拼 *.localhost 反而能进。
新增 proxyBaseDomains 返回全部候选基域(配置值 → base_url 主机名
→ localhost),serveProxyHost 用它匹配。两者都认,不是二选一:
*.localhost 是 RFC 6761 的零配置基座,不能丢。
3. 前缀模式下上游根绝对路径重定向逃出挂载点
实测 NapCat:上游 GET / → 301 Location: /webui,浏览器跟随到
https://<门户>/webui(已跑出 /napcat),门户 mux 接住 → 302 /login。
httputil.ReverseProxy 不改写 Location,在 ModifyResponse 里补:
只改以 / 开头且未带前缀的,避开 //host 与已经带前缀的(否则会出现
/napcat/napcat/webui)。
顺带纠正:base_domain 保持原配置不动,改由代码兼容两种基域 ——
改配置会抢走别的 *.jianfgit.xyz 子域,且 *.jianfgit.xyz 是单层通配,
huawei-smarthome.homeagent.jianfgit.xyz 本来就证书不匹配(实测
verify error 62)。要外网可用子域形态,应把 base_domain 配成
jianfgit.xyz,那是运维决策,不在本次改动内。
|
2026-11-04 17:15:43 +08:00 |
|
|
|
0c959e3ff3
|
fix(rtchild): pump 裸读写 data race —— 锁贯彻到底
## 缺陷(-race 实测)
rtChild 里 pump 是**唯一**"构造后被改写、又有并发读者"的字段,
但它的读写绕过了 rc.mu:
Dispose(StopRTChild 的 goroutine) rc.pump = nil ← 写
rtEmit (rtLoop 的 goroutine) rc.pump ← 读
-race 栈:
Read at … core.(*Agent).rtEmit rtchild.go:1275
Previous write … core.(*rtChild).Dispose rtchild.go:377
## 为什么之前没被发现
TestRTChild_E2E_Sustained 覆盖同一个窗口,但它是**偶发命中**:
全量负载下 5 次中 1 次,单跑 20 次 0 次,基线 af17af5 全量 0 次。
这种形状在 CI 里等于没有 —— 红了是运气,绿了不代表安全。
## 修法:锁贯彻到底(不是只补这一处)
rtChild 全部字段逐个判定过:
字段 构造后写? 遵守锁规?
pump 是(Dispose) ★ 本提交修:读写都持 rc.mu
light/mainRO 是(Dispose) ✓ 无并发读者,只在 Dispose 内用
slot/input/output/agent 否 ✓ 只读,不需要锁
history 是(append) ✓ 三处访问全部持锁(原有)
round/lastSeq/curCancel/critical/suppressedCancels ✓ 原本就持锁
即:pump 是唯一的漏网者,其余要么本已正确、要么根本没有并发写。
## 两个顺序要求(不只是"加个锁")
① **取走并置 nil 在锁内,Close 在锁外**
pump.Close() 内部 wg.Wait() 等消费 goroutine 收尾;持锁等待可能与
另一个持锁路径交叉。而"置 nil"必须与读者的"读"互斥,否则读者会
拿到一个已被清掉的指针。
② **rtEmit 锁内取、锁外 Push**
Push 在队列满时会阻塞,持锁会卡死 Dispose 的置 nil。
## 判据(2 条,2 次变异全部实测变红)
TestRTPumpAccessIsLocked
一边不停 emit、一边 Dispose,用 -race 判定。
变异 A(rtEmit 裸读) ⇒ 连跑 3 次全部命中 DATA RACE
变异 B(Dispose 裸写) ⇒ 第 1 次命中 DATA RACE
TestRTPumpAfterDisposeIsNil
钉住顺序语义:Dispose 之后 rc.pump 必须为 nil(否则 emit 会推
送到已关闭的 pump),且兜底路径不 panic。
## 判据第一版是假的(记下来)
第一版读者写的是 `for i := 0; i < 500; i++` —— 500 次 emit 微秒级跑完,
而写者 sleep 1ms 才动手,两者**根本不重叠**。变异 B(裸写)连跑 6 次
都不红。改成"持续跑到写者宣布结束"之后才真正抓到。
这正是「判据必须能变红」的意义:不跑变异就永远不知道它是真判据还是摆设。
## 判据的边界(诚实说明)
这条判据的"红"依赖 -race 检测器。不带 -race 时它只能发现 panic/死锁。
所以必须与 `go test -race` 一起跑 —— 提交纪律第 4 条已要求全量 -race,
这里只是把「哪个窗口最危险」钉成可复跑的形态。
## 验证(提交纪律四项全绿)
go build ./... 0
go vet ./... 0
gofmt -l internal/ cmd/ pkg/ 干净
go test ./... -count=1 -race 0 竞态,62 包全过
干净检出(worktree) build + TestRTPump 全绿
|
2026-11-04 16:53:13 +08:00 |
|
|
|
8fa6f36982
|
fix(agent): 插件崩溃熔断从不生效 —— 冷却锚点用了从未赋值的字段
## 缺陷(写上一个提交的判据时撞出来的)
recordCrash 标记 Unhealthy=true,但 isHealthy 的冷却判据是:
time.Since(r.LastReload) > reloadCooldown
而 LastReload **只在 markReloaded 里被赋值**(即"重载成功"之后)。
插件刚崩、从未重载时它是**零值**:
time.Since(零值) ≈ 2000 年 >> 30s
⇒ 判"冷却期早已过去" ⇒ 立刻把 Unhealthy 置回 false
实测(连打 3 次 recordCrash):
crash #1 → needReload=false healthy=true
crash #2 → needReload=false healthy=true
crash #3 → needReload=true healthy=true ← 刚标完就自愈
## 影响
三处「插件不健康则跳过执行」全是**死分支**:
task.go:1065 stepToolBegin 中的跳过
task.go:1135 (上一提交刚补的那个分支的另一处同构)
toolbatch.go:155 并发批次中的跳过
即:一个正在崩溃循环的插件会被**持续调用**,每次调用再崩一次。
recordCrash 返回的 needReload=true 也没意义 —— 下一句 isHealthy 就说它健康了。
pendingReloads 用同一个零值字段 ⇒ autoReloadPlugins 每次都会尝试重载
**刚崩的**插件,很可能立刻再崩(崩溃循环)。
## 修法:冷却锚点改为 LastCrash
语义上「熔断」是"刚崩完先别用它,等 30s 再看" —— 观察窗口应锚在
**最近一次崩溃**,而不是"最近一次重载成功"。
isHealthy 锚点 LastReload → LastCrash
pendingReloads 锚点 LastReload → LastCrash
## 顺带删掉冗余
unhealthyPlugins() 零生产调用方(全仓 grep 只有定义本身)
record.FirstCrash 只写不读(写它的唯一目的是被下面这个判断用,
而那个判断早就改成只看 LastCrash 了)
## 判据(5 条,3 次变异全部实测变红)
TestCircuitBreakerActuallyOpens ★ 主判据
变异:锚点改回 LastReload ⇒ "★ 连续 3 次崩溃后插件仍被判健康"
TestCircuitBreakerWaitsForCooldownBeforeReload
变异:pendingReloads 锚点改回 LastReload ⇒
"★ 刚崩完就进了重载队列(应等冷却期):[p]"
TestCircuitBreakerRecoversAfterCooldown
变异:删掉恢复分支 ⇒ "★ 冷却期过后仍未恢复健康 ——
插件会被永久停用(比不熔断更糟)"
TestCircuitBreakerUnknownPluginIsHealthy 从未崩过的插件不记账
TestCircuitBreakerWindowResets 窗口外的旧崩溃不该累加
## 判据本身的两个坑(已避开)
① 只断言 recordCrash 的返回值 —— 它本来就返回 true,测了等于没测。
必须断言 **isHealthy 的结论**,那才是被熔断逻辑消费的东西。
② 不测"冷却之后会恢复" —— 那样把代码改成恒 return false 也能过,
而那就成了永久禁用插件(比不熔断更糟)。
## 连带:deniedpair_test 的前置条件改回真实状态机
上一提交里,第二条判据只能**手工置 Unhealthy**(因为 recordCrash 标完
立刻被自愈撤销)。现在熔断真生效了,改回用 recordCrash 打满阈值 ——
判据依赖公开状态机而不是私有字段。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + TestCircuitBreaker/TestDenied 全绿
|
2026-11-04 16:34:46 +08:00 |
|
|
|
c9be04fdac
|
fix(agent): 被拒绝/被跳过的工具没落 ToolResults —— call 与 result 失配
## 根因(4ab888c 的自检抓出来的)
4ab888c 在发送前加了配对自检,生产日志立刻指出真正来源:
★ dropped 1 unpaired tool message(s) before send (names: [qq_call_state])
顺着工具名查 context.json,脏帧是决定性的:
[42] tool_calls = [qq_call_state, output_send__qq]
tool_results = [output_send__qq] ← call_state 从未落盘
该帧自己的记录写了原因:qq_call_state 被 QQ 插件的来源权限策略拦下
(判定为"非所有者私聊")。
## 缺陷位置
stepToolBegin 的顺序是「先记 call,再判能不能执行」:
f.appendToolCallRecord(tc) // ← 先落 ToolCalls
if a.runStage(sdk.StageBeforeToolcall, …) {
f.Msgs 补一条 role=tool // ← 模型能看到
f.ToolIdx++
return outcomeContinue // ← 但没写 ToolResults
}
同一函数里还有一处同构分支(插件崩溃态跳过执行),同样只补 f.Msgs。
task.go 顶部的注释写着「ToolCalls 与 ToolResults 通过 CallID 配对;
两者都由 appendToolCallRecord 同步追加」—— 这个不变量在**提前返回路径**
上被违反了。
## 为什么这会变成会话级故障
ContextEvent{ToolCalls: 2, ToolResults: 1}
⇒ 渲染出 assistant(tool_calls=[c1,c2]) 但只有 c1 的结果
⇒ 上游硬拒绝(HTTP 400):
An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages…)
关键在于脏数据**持久化进 context.json**,之后每一轮都带着它,直到自检剔掉。
## 修法
新增 appendToolResultRecord(与 appendToolCallRecord 成对的公开入口),
在两处提前返回各补一行。
判定"被拒绝/被跳过"≠"没有结果":拒绝原因本身就是结果,而且模型确实
从 f.Msgs 看到了它。落 context 的那份必须与发给模型的一致。
并发路径(stepToolBatch)没有这个问题 —— runOneTool 只返回结果,
join 后的顺序收尾段无条件写 ToolResults(含被拒绝项)。
## 判据(2 条,2 次变异全部实测变红)
TestDeniedToolStillRecordsResult
注册一个真的会拒绝的 before_toolcall 钩子,跑完整一轮,
断言落进 context 的每个 call 都有 result,且拒绝原因文本一致。
变异:删掉拒绝分支那行 ⇒ "★ call call_deny_1 (denied_tool) 没有对应的
ToolResults"(实测红)
TestUnhealthyPluginSkipStillRecordsResult
钉住第二条同构分支。
变异:删掉跳过分支那行 ⇒ "★ 被跳过的工具 call_unhealthy_1 没落
ToolResults —— 同型失配,上游 400"(实测红)
两条都走真实链路(真的调 runStage / 真的置崩溃态),不是只测
appendToolResultRecord 这个函数本身。
## 顺带发现一个独立缺陷(未修,不在本次范围)
写第二条判据时发现:插件崩溃熔断**实际从不生效**。
recordCrash 标记 Unhealthy=true,但从不设置 LastReload
⇒ isHealthy 的冷却判据 time.Since(r.LastReload) > reloadCooldown
在 LastReload 为零值时恒为真(Since 是巨大的正数)
⇒ 判定"冷却期早已过去" ⇒ 立刻自愈
实测:连续 3 次 recordCrash 后 needReload=true、Unhealthy=true,
紧接着 isHealthy 返回 true。
后果:task.go:1065/1135 的「插件崩溃态跳过执行」分支实际走不到。
这条应单独修并单独钉,本提交的判据用直达状态绕过它(注释已写明原因)。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + TestDenied/TestUnhealthy 全绿
|
2026-11-04 16:24:49 +08:00 |
|
|
|
4ab888ce06
|
fix(agent): 发送前自检 tool_call 配对 —— 止损并定位上游 400
## 为什么还要修(前面两个修复都不够)
今天已经修了两处:
263b651 并发批次 stepToolBatch 漏记 ToolCalls
0ed3c89 L1 预算截断切开 tool 配对
并用生产 context.json 验证历史已干净:
53 个事件,0 个"结果多于调用"的帧
但 A2A **新会话**(a2a_1793778753015670668)仍然报同一个 400:
An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages following...)
同时日志显示 `ratio=0.28`(远低于 1.25 阈值)、`skip oversized frame` 计数 0
⇒ 既不是裁剪,也不是预算截断。
## 定位:问题在 f.Msgs,不在 L1 历史
`f.Msgs` 是**多条路径**拼出来的:
prepare 重建 buildMessages
中断恢复 rebaseFramePrefix(重建 prefix + 接回 tail)
批末附加 flushBatchAttachments
占位补全 dropContinuationPlaceholders / continuation
任何一条漏掉配对,最终都表现为同一个 400,而协议**不告诉你是哪条 call**。
所以把自检放在唯一一次组装 provider 请求的位置(stepLLM,task.go:750),
而不是散在各条路径上。
## 为什么剔 call 而不是补占位
补一条 "tool result: (missing)" 会让模型看到它**从未调用过**的结果,
可能据此继续推理 —— 那是编造事实。剔掉 call 只是少了一次尝试,
模型可以重来,语义安全。
被剔的 call 记录工具名与数量,日志里能看出是哪一轮出的问题。
## 判据(5 条,3 次变异全部实测变红)
DropsCallWithoutResult 主方向:没有结果的 call 必须剔
变异:直接 return msgs ⇒ "★ 未配对的 call c2 仍在消息里 —— 上游必然 400"
DropsOrphanToolResult 反方向:找不到 call 的孤立 tool 必须删
变异:删掉第二遍循环 ⇒ 红
DropsEmptyAssistantShell call 全剔且正文为空 ⇒ 整条删
变异:去掉该分支 ⇒ 红
KeepsContentWhenAllCallsDropped
call 全剔但**有正文**时保留正文(那是模型说的话,不能丢)
LeavesValidSequenceUntouched
合法序列必须逐条不变 —— 修正逻辑不能引入新问题
只测一个方向会放过另一半失配,所以两个方向都钉。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + TestDropUnpaired 全绿
## 下一步
这条是**止损 + 定位**,不是根因修复。部署后如果日志里出现
"dropped N unpaired tool message(s)",那 N 与工具名就指向真正漏配对的
那条路径,据此再修上游。
|
2026-11-04 15:58:22 +08:00 |
|
|
|
1b6cd57dca
|
fix(skillmgr): 内核旧版 skill 永不更新 —— 无 stamp 不等于"用户改过"
## 缺陷(实测,非推测)
给 kernel-plugins 补上 `skill_info(name, section=...)` 的用法说明后,
改了嵌入内容,但**生产上永远不会更新**:
/home/hologramagent/skills/kernel-plugins/ mtime 2026-10-26,无 .builtin-sha256
嵌入版本 1.1.0 vs 磁盘版本 1.0.0
`materializeBuiltinSkills` 的判断链是:
builtinSkillUnmodified(dir) → 读 .builtin-sha256
没有 stamp ⇒ false
⇒ continue("用户改过或无法确认,保留人的版本")
而 stamp 机制是 387ae94(今天早些时候)引入的。生产机上这份 skill
是**更早的内核**写的 —— 它没有 stamp,于是被永久判为"来历不明"。
后果不只是这一份文档:任何在 stamp 机制之前部署的机器,其内核自带
skill 都停在旧版。而 kernel-* 系列正是教模型怎么用工具/记忆/终端的
文档 —— 内核改进了文档,模型永远看不到。
## 修法:用 frontmatter version 区分两种"没有 stamp"
原判据把两件事混成一件:
用户改过措辞 ⇒ 必须保留
内核在 stamp 之前写的 ⇒ 应该升级
新增 builtinSkillOutdated(dir, embedded):只在**没有 stamp** 时被调用,
判据是磁盘 frontmatter 的 `version` 严格低于内嵌版本。
安全性分析(写在代码注释里):
人改措辞 通常不动 version ⇒ 不低于 ⇒ 保留 ✓
人把 version 改高 ⇒ 保留 ✓
内核旧版(无 stamp) ⇒ 升级 ✓(这正是要修的)
解析不出版本号 ⇒ 返回 false ⇒ 保留(保守)
误升级的唯一条件是"人把 version 改低",而那本身就意味着放弃内核版本。
原有的四条保守判据(含 NoStampIsConservative)全部保持绿。
## 判据抓到了我自己写的 bug
TestCompareVersion 立刻红了:
compareVersion("1.0","1.0.0")=-1,期望 0
原因:缺失段留成空串 ⇒ Atoi("") 失败 ⇒ 落到字符串比较 ⇒ `"" < "0"`
⇒ 版本号少写一段的 skill 会被无谓地重复升级。改为缺失段视为 "0"。
## 变异验证
builtinSkillOutdated 恒 return false(= 修复前行为)
⇒ TestMaterializeBuiltinSkills_UpgradesStaleKernelVersion 红:
"★ 没有 stamp 的内核旧版(version 0.0.1)没被升级"
compareVersion 缺失段改回空串
⇒ TestCompareVersion 红:"compareVersion(\"1.0\",\"1.0.0\")=-1,期望 0"
## 顺带:kernel-plugins v1.1.0
补一节「Reading a skill without burning context」,说明:
- 长文档返回目录 + 开头 30 行,目录每行带 (N 行)
- `section` 可按编号或标题子串取,大小写不敏感,容忍 `##` 前缀
- 顶层章节返回整棵子树
- 未命中会列出全部可用章节
- 章节边界尊重围栏代码块
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + skillmgr 全绿
(包数 63 → 62 是因 clawhubadapter 已在 a0077a2 迁出为外部插件,与本改动无关)
|
2026-11-04 15:50:39 +08:00 |
|
|
|
a0077a2bad
|
refactor(plugin): clawhubadapter 迁出为外部插件
## 为什么迁
它是唯一还留在内核仓的「兼容层」插件,而它的全部工作都是对接**外部生态**
(ClawHub/OpenClaw 的包格式),跟内核版本无关。留在仓里有两个实际问题:
· 外部生态的新包格式跟进要走内核发版
· 它必须跟着内核一起重新编译,而它并不依赖内核内部实现
## 迁移改动(逻辑一行没动,只换接口形态)
package clawhubadapter → package main
init()+RegisterFactory → NewPluginFactory(proc 运行时按名调用)
internal/sdk → hologramagentsdk/sdk
internal/safeclose.Safe → 自写 safeClose[T](泛型覆盖 3 种 channel)
InjectInputSync(4 参) → InjectInputSync(3 参, 返回 string)
s.Events().Publish → 删除(外部插件拿不到,也不需要)
## 为什么删掉 Events().Publish 而不是找替代
原实现靠它把纯 SKILL 条目移交 skillmgr。查清后发现**不需要**:
pluginmgr 在安装与每次启动时扫 `<插件目录>/skill/` 自己发 skill_detected
(internal/plugins/pluginmgr/skill_handout.go)。外部插件带 skill 走
工程根 skill/ 目录,与插件能不能发事件是两件事。
链路上没有一行来自插件自身,实测日志:
[pluginmgr] clawhubadapter: handed off skill clawhub-tools to skillmgr
[skillmgr] registered via skill_detected event: clawhub-tools
[plugin] loaded SKILL: clawhub-tools v1.0.0
## 验证
迁移工程 /home/program/clawhubadapter-plugin/clawhubadapter
go vet / gofmt 干净
go test -race ok(20 条原有 + 4 条外部形态判据)
hmapdev build packaged clawhubadapter_bundle.hmap
变异验证 4 条判据全部真红
隔离实例端到端(真实安装路径,非模拟):
registering tool: clawhubadapter_* × 10
[plugin] loaded: clawhubadapter
[plugin] loaded SKILL: clawhub-tools v1.0.0
内核仓:go build / vet / gofmt / go test -race 全绿,62 包
## 生产切换的注意点
本 commit 只删源码,**不改生产**。生产 homed 里仍编着内置版;
要真切换必须先装 .hmap 再重编 homed,否则等于把这个插件弄没了。
|
2026-11-04 15:23:53 +08:00 |
|
|
|
67be114647
|
feat(skillmgr): skill_info 支持按章节读取 —— 长 SKILL 不再一次吞一万 token
## 问题
skill_info 此前**整份返回** SKILL.md。生产最坏一例:
commondev-discipline v2.3.0 → 983 行 / 53810 字节 / ≈10600 token
模型为查「怎么开 PR」(§14,21 行)必须把 hmap 插件开发纪律(320 行)
和红牌一起读进上下文。一次误读一万 token,还挤掉了真正要用的历史。
拆 skill(一个插件声明多个)能解决"跨文档绑定",但解决不了
"单文档内部按需读" —— 本提交补的就是后者。
## 改法
skill_info(name, section=...) 只返回匹配的章节。不传 section 时:
短文档(<=200 行) 直接返回全文(加目录只是噪音)
长文档(>200 行) 返回「目录 + 开头 30 行」,模型据此决定读哪节
目录每行都标 `(N 行)` —— 行数不是装饰,它是模型在"一次吞一万 token"
和"先读目录"之间做选择的唯一依据。
## 三个实现要点
① **切分必须感知围栏代码块**
SKILL.md 里有大量 shell 注释:
```bash
# 期望 200;401/403 = 凭据无效
curl -s ...
```
硬按 `#` 切会把一条 curl 示例拆成两半 —— 模型拿到的是无法执行的
碎片,比不给还糟。parseSections 跟踪 ``` / ~~~ 围栏,块内不切。
② **返回行号区间,不复制字符串**
章节内容的唯一事实来源仍是文件本身,目录与正文用同一套边界计算,
不会漂移成两份。
③ **点名顶层章节给整棵子树**
问 `## Setup` 时要的是 §11–§16 全部,不是只有标题那一行。
边界 = 下一个**同级或更浅**的标题。
## 判据(均经变异验证)
TestSectionsRebuildEqualsOriginal ★ 主判据
所有区间拼接必须逐字节等于原文
一次同时抓漏行 / 重行 / 错位三类缺陷
变异:end 改 heads[i+1].line-1 ⇒ "原文 16 行,拼回 13 行"(实测红)
TestSectionsShellCommentIsNotHeading
变异:删掉 fence 判断 ⇒ "代码块内的 shell 注释被当成标题:
\"期望 200;401/403 = 凭据无效\"(行 4)"(实测红)
TestResolveSectionReturnsWholeSubtree
变异:`<=` 改 `<` ⇒ "Setup 越界吃进了 Usage 的内容"(实测红)
TestResolveSectionToleratesHashPrefix
模型常把 `## Setup: git 操作` 整行传进来,必须容错
TestRenderTOCListsLevels
目录只列到 maxLevel,且必须带行数
## 判据本身最容易写废的地方(已避开)
① 用人造短文档测切分 —— 那种文档没有 shell 注释、没有嵌套层级、
没有围栏代码块,测过了不代表能切真实文件。
主判据**用生产那份 983 行的真实文件**。
② 只断言"切出 N 个章节" —— 数量对不代表边界对。
## 真实数据验证
文档 983 行 → 切出 46 个章节(## 顶层 7 个)
区间拼接 == 原文(逐字节)
section="审批" → 21 行(§16)
section="Setup" → 123 行(含 §11–§16 全部子节)
section="hmap" → 320 行
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(worktree) build + skillmgr 全绿
|
2026-11-04 15:06:04 +08:00 |
|
|
|
0ed3c894f5
|
fix(agent): L1 预算截断切开 tool 配对 —— 这才是上游 400 的真因
## 上一个提交修的是另一半,这是主因
263b651 修了"并发批次漏记 ToolCalls"(历史脏数据)。但**新会话仍然报同样的错**,
说明还有一条独立的路径在制造非法序列。
## 定位过程
① 先读内核自己写的日志(homed_<时间>.log):
overflow check #1: ratio=1.21 events=41 (threshold=1.25)
上下文**没触发裁剪**(1.21 < 1.25)——所以不是裁剪切断配对。
② 于是直接拿生产 context.json 的真实数据跑渲染,按 call_id 统计配对:
budget=480000 → 469 calls / 386 answered / **83 dangling**
budget=100000 → 469 calls / 40 answered / 429 dangling
**默认预算下就已经在切断。**
## 根因
renderL1Messages 的预算截断是**逐条消息**累加的:
for _, m := range msgs {
if maxTokens > 0 && used+cost > maxTokens && len(out) > 0 {
return out // ← 可能切在 assistant(tool_calls) 之后、tool 之前
}
out = append(out, m)
}
`msgs` 是一组 [user?, assistant(tool_calls), tool, tool, ...]。`return` 落在
组中间 ⇒ 序列里留下一个永远等不到结果的 tool_calls ⇒ 上游 400。
这与 InactivePages 的约束是同一件事(那边注释已写着"剔掉 call 却留下 result
就是造一个必崩的帧")—— **整组要么全留,要么全丢**。
## 修法:以事件(消息组)为截断单位 + 跳过失控帧
① 组内完整:groupCost 一次算完,整组 append 或整组不 append
② 单组超过预算 1/4 视为**失控帧**(实测见过 468 个 tool_call 的单帧,
51 万 token),此时跳过它继续往后渲染 —— 否则它会把之后的全部
历史一起吞掉(实测 49 个事件只剩 8 个,上下文几乎清空)
② 的判据只看"这一组自己是否失控",**不看 out 是否为空**:
第一版写成 `len(out)==0 || groupCost*4 <= maxTokens`,于是失控帧恰好
排在第一位时仍然 return —— 判据实测抓到"渲染出 0 条消息"。
## 判据(均经变异验证)
TestRenderBudgetNeverSplitsToolPairing
扫 8 个预算档位,任何档位都不得有悬空调用
变异:改回逐条累加 ⇒ budget=50 时 "call ca0 无配对结果"(实测红)
TestRenderOversizedFrameDoesNotDropNewerHistory
失控帧之后的新对话必须仍然可见,且跳过时不产生悬空
变异:去掉"跳过"分支 ⇒ "渲染出 0 条消息"(实测红)
## 用生产真实数据验证修复效果
修复前:budget=358553 → 14 条消息(5 assistant / 1 tool / 8 user)
修复后:budget=358553 → 236 条消息(41 assistant / 147 tool / 48 user)
dangling=0,四个预算档位均为 0
即:不再有非法序列,且上下文不再被单帧吞空。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + TestRender* 全绿
|
2026-11-04 14:15:33 +08:00 |
|
|
|
263b651bf0
|
fix(agent): 并发批次漏记 ToolCalls —— 修上游 400 与 A2A 会话不可用
## 故障现象
生产 A2A 只回一句错误:
An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages following
tool_calls message)
所有 provider 全部失败,那条会话彻底不可用。
## 排查方法:读内核自己写的日志
内核每次启动写一份 homed_<时间>.log。在其中看到:
overflow check #1: ratio=1.21 events=41 (threshold=1.25)
上下文**没有**触发裁剪(1.21 < 1.25)——所以不是裁剪切断配对。
于是查 context.json 的实际数据,按 call_id 配对统计,发现 4 个事件
出现"结果多于调用":
[17] calls=18 results=20
[22] calls= 1 results= 3
[28] calls=14 results=20
[35] calls= 5 results= 7
## 根因
帧里有两条执行路径,各自记录:
stepToolBegin → appendToolCallRecord(tc) ← 记 ToolCalls + ToolsUsed
stepToolExec → append ToolResults (串行路径,两条都记)
stepToolBatch → append ToolResults (并发路径,只记结果!)
并发路径(stepToolBatch)**从不调 appendToolCallRecord**。
于是落进 ContextEvent 的数据里 ToolResults 多于 ToolCalls。渲染时
每条 result 都生成一条 role="tool" 消息,而配对的 assistant(tool_calls)
里没有这个 call_id ⇒ 上游 400 ⇒ 所有 provider 失败。
同时还漏了 ToolsUsed(它与 ToolCalls 在 appendToolCallRecord 里同处维护):
并发批次的工具从未被记进"用过的工具",记忆/统计/页化都在少算。
## 为什么不是本次内核升级引入
对比日志:ba8a33d(升级前)今天也有 3 次同样错误。它是既有缺陷,
只是并发批次越多越容易触发。本次排查顺带定位并修掉。
## 修法
在 join 之后的**顺序收尾段**补一行 appendToolCallRecord:
f.appendToolCallRecord(f.CurTool)
f.ToolResults = append(...)
为什么放这里而不是 fan-out 里:函数注释已明确 f.Msgs / f.ToolResults
在 goroutine 期间一律不碰。这里是按索引串行的收尾段,与单步路径语义一致。
## 判据 TestBatchContextEventPairsCallsWithResults
三条:调用记录齐全、每条结果有配对、每个调用有结果(双向)。
★ 已有 TestBatchToolCallIDsAllPaired 没抓到,因为它只看 f.Msgs ——
而 Msgs 里的 assistant 消息由 ensureBatchAssistant 单独写入(含全部
PendingTools),不经过 f.ToolCalls。所以 Msgs 侧配平、落盘侧不配平。
判据必须落在**要落盘的那份数据**上。
## 变异验证(两次才对)
第一次:删掉修复行 ⇒ 判据**没红**。原因:测试里的工具没声明
ParallelSafe,batchRunnable 判 false ⇒ 走串行路径(stepToolExec),
根本没测到目标代码。
第二次:给工具加 ParallelSafe + 断言 ConcurrencySafeOf 为真
(确保真的走并发),再删修复行 ⇒
★ f.ToolCalls 只有 0 个(map[]),期望 2 个
这才是有效验证。教训:**判据必须断言自己走到了目标路径**,
否则"删掉实现仍绿"会被误读成"实现不必要"。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + TestBatch* 全绿
|
2026-11-04 14:02:58 +08:00 |
|
|
|
a8ce5ece30
|
feat(agentcli): 补 skill 文档 —— 终端 vs 一次性命令的边界
## 为什么补这个
真实生产事故:agent 把 terminal_create 当成一次性命令执行器用。
terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && echo "=== 已安装的 dsh 包 ===" && ls -d */ | head -60 && …')
→ read /dev/ptmx: input/output error
→ 再开 term_7、term_8、term_9 …
三层成因:
① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run,需先 plugin_start cmd
② terminal_create 的 command 参数描述写着"要执行的命令",语义上像"跑一条命令"
③ 没有文档说清"一次性命令该用 cmd_run、终端是给要持续输入的程序用的"
①③ 已分别修掉(cmd-tools skill + 本文件)。② 是设计上的引力:
参数名叫 command、描述说"要执行的命令",模型自然会塞复合命令进去。
文档要正面抵消。
## 实现方式
按 seq 的既有模式:内置插件实现 plugin.SkillProvider,
由内核统一写盘/幂等/事件/停用清理(internal/plugin/builtin_skill.go)。
· 新增 internal/plugins/agentcli/skill.go
· BuiltinSkills() 交出正文,生命周期与插件同生共死
(禁用插件后 skill 还留着 = 模型看得到说明、点进去工具却不存在)
## 文档的核心:可操作判据,不是抽象词
一句判据:**跑完就结束 → cmd_run。要一直活着、要看屏幕、要按键 → terminal。**
关键的三条(缺任何一条模型都会退回旧行为):
· 点明 cmd 插件要 plugin_start(否则模型找不到 cmd_run 就用终端)
· 点明复合命令(cd && echo && ls)属于一次性,不是"所以要用终端"
· 解释 /dev/ptmx 那个真实报错的含义(命令跑完终端已退出),
否则模型会以为终端坏了
索引行(模型每轮唯一能看到的一行)也带边界:
"交互式终端(vim/ssh/top/长驻进程)。一次性的 shell 命令不要用终端,用 cmd_run。"
## 判据(均经变异验证)
TestSkillProviderContract 编译期断言 SkillProvider + 正文非空
TestSkillSeparatesTerminalFromOneShot ★ 六条必备内容缺一即红
TestSkillIndexLineStatesBoundary 索引行必须指出替代方案
TestSkillBodyFirstParagraphStandalone 正文首段(真正注入提示词的那段)能独立看懂
变异验证(实测):把整份文档换成"终端可以执行各种命令"的弱化版本
⇒ 3 条同时红。
★ 第一版变异是"只改一句话",判据没红 —— 因为 cmd_run 在文中另有 13 处。
那次变异无效,不算验证。改成整份替换后才是真验证。
## 端到端验证(独立实例 + 假 LLM 捕获真实请求)
[plugin] agentcli: installed skill "agentcli-terminal" -> .../skills/agentcli-terminal
[clawhubadapter] skill agentcli-terminal detected, handed off to skillmgr
实际注入到 system prompt 的 [Skills] 段:
agentcli-terminal v1.0.0 - 终端给**要持续输入**的程序用(vim、ssh、top、REPL、
要按键确认的安装器);一次性的 shell 命令用 **cmd_run**(cmd 插件,需…
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 判据全绿
独立实例实测 真机装盘 + 真机注入提示词(见上)
|
2026-11-04 13:22:07 +08:00 |
|
|
|
01c7c0145e
|
fix(kernel): 补上统一挂载点的生产接线 —— 上一版功能实际未生效
## 这个 commit 修的是我自己上一版的破绽
上一轮我报告"63 包全绿"。今天用户问「确定都可以正常工作」,
一查发现:**生产路径上根本没人调 kstate.Mount()**。
$ grep -rn 'kstate.Mount(' --include='*.go' internal/ cmd/ | grep -v _test
(空)
后果链:
kstate.Get() 永远返回 nil
⇒ agent/core 的 renderSystemPrompt 走 `if ks != nil` 跳过分支
⇒ 插件声明的系统提示词段**一条都不渲染**
⇒ 模型看不到任何 skill 索引,只能凭工具名猜用法
⇒ 正是本次重构要修的那个生产故障(拿 terminal_create 当 cmd_run)
而且**日志里一个字都没有** —— 静默跳过,不是报错。
## 为什么测试全绿
因为测试自己调了 `MountForTest`:
restore := kstate.MountForTest(&kstate.State{PromptSections: reg})
t.Cleanup(restore)
判据把缺失的那根线补上了,所以永远绿。这正是本仓反复吃过的亏
(engineering-lessons §1.1)。
## 一共两个断点
① kernelboot.Run 没调 kstate.Mount(ks)
② sdk.SetSystemPromptAPI 生产上没人调
⇒ 插件的 s.SystemPrompt() 返回 nil
⇒ skillmgr.publishIndex() 对 nil 是静默 return(它不能 panic)
## 修复
① kernelboot.Run 装入挂载点(唯一入口)
② 补齐三段接线:
plugin.Registry 加 promptSections 字段 + SetPromptSections
kernelmod/pluginhost 从 ks.PromptSections 注入进去
internal/sdk 收到后调 SetSystemPromptAPI(promptSectionAdapter)
## 新增判据:**不自己 Mount**,只读 Run 之后的状态
TestKstateIsMountedByRun 源码判据(那行必须在)
TestPromptSectionsWiredToPlugins 三段接线逐个核对
TestSharedDataInjectionsHaveProducers 通用:注入点必须有生产者
TestRunActuallyMountsState ★ 端到端
最后一条是关键:它先 `MountForTest(nil)` **清空**挂载点,再跑真实
`kernelboot.Run` + 真实模块登记表,然后从底层包视角(kstate.Get())
检查能不能看到东西。它不能靠"测试自己挂"蒙混过关。
变异验证(实测):
· 删掉 kstate.Mount(ks) ⇒ 源码判据红 + 端到端判据红
「kernelboot.Run 跑完了,但 kstate.Get() 仍是 nil」
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 端到端判据全绿
六环人工核对 ①Mount ②Run ③产出 ④注入 ⑤sdk ⑥skillmgr 各 1 处
## 教训
"全绿"只在**判据不自己补线**时才是证据。凡是"被测方需要某个前置
装配、而判据自己提供了它"的判据,都必须再配一条"从零开始"的
端到端版本。
|
2026-11-04 12:41:56 +08:00 |
|
|
|
8cc8bca380
|
test(kernel): 架构防腐判据 + 修 migrateEntry 的并发缺陷
## 判据(internal/kernelmod/all/layering_test.go)
六条,全部经变异验证:
TestCoreDoesNotImportKernelboot core 不依赖装配引擎
TestPluginDoesNotImportKernelboot plugin 不依赖装配引擎
TestSdkDoesNotImportKernelboot sdk 不依赖装配引擎
TestSecurityDoesNotImportKernelboot security 不依赖装配引擎
TestKstateDoesNotDependOnCore kstate 不依赖 core("core 能读挂载点"的另一面)
TestSecuritySingletonGuardStillExists 单例判据文件不得被删
TestAgentHasNoPromptSectionsField Agent 不得再持有提示词段字段
TestPluginSettersAreInjection 防过度收敛:plugin 不得读 kstate
TestPromptSectionsOnlyFromKstate 内核只从挂载点读插件段
用 `go list -deps` 而不是源码扫描:传递依赖能抓到间接环。
## 为什么需要"防过度收敛"这一条
kernelplan v1 曾主张把 plugin 的 12 个 SetXxx 改成"读 kstate"。实测做不到:
kstate.State.PluginRegistry 的类型是 *plugin.Registry
⇒ kstate 依赖 plugin
⇒ plugin import kstate 就是 import cycle(已用探针实证)
更重要的是——它们**本来就不是缺陷**:每个 Setter 只有一个调用点,
调用方持有 ks、显式传参;少了任何一个插件拿 nil 立刻报错,
不存在"静默降级"(那才是包级单例的病症)。
TestPluginSettersAreInjection 防的是后来者看到一堆 Setter 就想合并成
全局注册表,那会把"显式注入"退化成"隐式全局"。
## 顺带修一个真缺陷
migrateEntry 在**解锁之后**调 saveLocked()。而 saveLocked 遍历 s.data
序列化 —— 解锁后再调就是在读一个可能被并发改写的 map:
· data race(-race 可复现)
· 可能写出半更新的快照(部分条目是新密文、部分是旧密文)
函数名里的 Locked 就是要求"调用方已持锁"。改为持锁期内调用。
判据:go test ./internal/security/ -race 全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
go test ./internal/security/ -race PASS
干净检出(git worktree) build + 判据全绿
## kernelplan.md 同步
T5 标记为"计划错误,已撤销",并记录实测依据 —— 避免下次又有人
想"顺手收敛掉"这十几个 Setter。
|
2026-11-04 12:28:43 +08:00 |
|