mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-15 08:51:49 +08:00
Compare commits
10 Commits
3d05dcac71
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b879558947 | |||
| d0e4f10b53 | |||
| b73ff3786a | |||
| 5700531169 | |||
| 29bfad63d6 | |||
| b215bfb3ad | |||
| a7e8f55085 | |||
| 41926e770d | |||
| f7dad243b6 | |||
| 319fa91ce7 |
129
internal/agent/api/test/reasoning_protocol_test.go
Normal file
129
internal/agent/api/test/reasoning_protocol_test.go
Normal 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 丢了")
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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] {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 里被向量化过一次,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
289
internal/lua/test/adapter_sync_test.go
Normal file
289
internal/lua/test/adapter_sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
261
internal/lua/test/deepseek_reasoning_test.go
Normal file
261
internal/lua/test/deepseek_reasoning_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 三层共同使用的记忆块值。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
181
internal/memory/coldcontext/test/reasoning_contract_test.go
Normal file
181
internal/memory/coldcontext/test/reasoning_contract_test.go
Normal 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 里混进了思考内容")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 层拒写。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 是块分配器视图。
|
||||
//
|
||||
|
||||
@ -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
|
||||
|
||||
210
internal/plugin/proc/test/arena_contract_test.go
Normal file
210
internal/plugin/proc/test/arena_contract_test.go
Normal 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 容量被接受(应报错而非回落默认值)")
|
||||
}
|
||||
}
|
||||
170
internal/plugins/cfgmgr/test/configuration_contract_test.go
Normal file
170
internal/plugins/cfgmgr/test/configuration_contract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user