Files
HomeAgent/internal/agent/core/task_test.go
JianFeeeee 9a588780b9 refactor(scheduler): M1 把 process() 拆成 step 状态机 + TaskFrame(行为等价)
设计依据 docs/zh/input-scheduler-design.md §14 M1。

- 新增 task.go:Step 游标、TaskFrame,以及 stepPrepare/stepLLM/
  stepToolBegin/stepToolExec/stepToolAfter/stepTurnEnd 六个 step;
  原函数内嵌的 provider 回退/重试抽为 resolveProviders + callLLMWithFallback
- process() 改为驱动状态机的薄壳:签名不变,调用方(processInput/
  processConsolidation/测试)零改动
- StepToolExec 显式标注为临界区:工具副作用不可回滚,执行中不是安全点
- 步数上限护栏:转移缺失时以错误退出而非死循环
- 新增 task_test.go:R3(工具往返结果与配对正确)、X3(多轮必然终止)、
  批中途中断必须放弃剩余工具、未知 step 必须失败退出
- 验收:既有 agent 全量测试通过;-race 通过(TMPDIR 指向真实磁盘,
  /tmp tmpfs 已 98% 满会导致链接失败,与代码无关)
2026-09-12 23:47:12 +08:00

210 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// M1 验收测试:状态机与 TaskFrame 的**行为等价性**。
//
// 设计依据 docs/zh/input-scheduler-design.md §11R3 / X3 的 M1 形态):
// M1 不引入抢占,因此 R3 退化为「经状态机跑出的结果与脚本预期一致」;
// X3 在 M1 退化为「驱动循环必然以一次终态返回结束(不空转、不超步数)」。
//
// 抢占/挂起/恢复/优先级在 M3 起才有测试。
import (
"context"
"errors"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// scriptProvider 按脚本依次返回 CompletionResponse。
//
// ChatStream 故意返回错误:驱动 chatStreamWithFallback 走非流式回退,
// 这样脚本就是「第 N 次调用返回第 N 个响应」,不依赖流式分片语义。
type scriptProvider struct {
script []*agentAPI.CompletionResponse
idx int
reqs []*agentAPI.CompletionRequest
}
func (s *scriptProvider) Name() string { return "script" }
func (s *scriptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
s.reqs = append(s.reqs, req)
if s.idx >= len(s.script) {
return &agentAPI.CompletionResponse{Content: ""}, nil
}
r := s.script[s.idx]
s.idx++
return r, nil
}
func (s *scriptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, errors.New("script provider: streaming disabled")
}
func (s *scriptProvider) MaxContextTokens() int { return 8192 }
func newTaskTestAgent(t *testing.T, sp agentAPI.Provider, sh *StageHost) *Agent {
t.Helper()
return New(AgentConfig{
ID: "task-test",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
StageHost: sh,
IO: agentIO.NewIOManager(),
})
}
func tc(id, name string) agentAPI.ToolCall {
return agentAPI.ToolCall{ID: id, Name: name, Arguments: map[string]interface{}{"q": id}}
}
// R3M1 形态):一次工具轮 + 一次收尾轮,结果与工具调用计数必须正确。
func TestTaskFrame_R3_ToolRoundTrip(t *testing.T) {
sh := NewStageHost()
var got []string
sh.RegisterTool("t_echo", sdk.ToolDef{Name: "t_echo", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
got = append(got, args["q"].(string))
return "OUT", nil
})
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "让我调用工具", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_echo")}},
{Content: "最终答复"},
}}
a := newTaskTestAgent(t, sp, sh)
stageCtx := a.stageCtxFromInput("你好", "", "")
resp, toolsUsed, toolResults, err := a.process("你好", stageCtx)
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "最终答复" {
t.Fatalf("响应=%q期望 %q", resp, "最终答复")
}
if len(toolsUsed) != 1 || toolsUsed[0] != "t_echo" {
t.Fatalf("toolsUsed=%v期望恰好一次 t_echo", toolsUsed)
}
if len(toolResults) != 1 || toolResults[0].Name != "t_echo" || toolResults[0].Output != "OUT" {
t.Fatalf("toolResults=%+v期望一条 t_echo/OUT", toolResults)
}
if len(got) != 1 || got[0] != "c1" {
t.Fatalf("工具实参=%v期望恰好执行一次且参数来自脚本", got)
}
if len(sp.reqs) != 2 {
t.Fatalf("LLM 调用次数=%d期望 2工具轮 + 收尾轮)", len(sp.reqs))
}
// 第二轮请求必须携带 assistant(tool_call) + tool 结果两条消息。
msgs := sp.reqs[1].Messages
var hasAssistantCall, hasToolResult bool
for _, m := range msgs {
if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].ID == "c1" {
hasAssistantCall = true
}
if m.Role == "tool" && m.ToolCallID == "c1" && m.Content == "OUT" {
hasToolResult = true
}
}
if !hasAssistantCall || !hasToolResult {
t.Fatalf("第二轮请求缺少工具调用配对assistant=%v tool=%v", hasAssistantCall, hasToolResult)
}
}
// X3M1 形态):多轮脚本必须在有限步内以一次终态返回结束。
func TestTaskFrame_X3_TerminatesWithinBudget(t *testing.T) {
sh := NewStageHost()
sh.RegisterTool("t_noop", sdk.ToolDef{Name: "t_noop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
return "ok", nil
})
// 3 个工具轮 + 收尾轮:状态机会在 StepLLM/StepToolBegin/.../StepTurnEnd 间往返 4 次。
var script []*agentAPI.CompletionResponse
for i := 0; i < 3; i++ {
script = append(script, &agentAPI.CompletionResponse{
Content: "round",
ToolCalls: []agentAPI.ToolCall{tc("c"+string(rune('a'+i)), "t_noop")},
})
}
script = append(script, &agentAPI.CompletionResponse{Content: "done"})
sp := &scriptProvider{script: script}
a := newTaskTestAgent(t, sp, sh)
resp, toolsUsed, toolResults, err := a.process("跑三轮", a.stageCtxFromInput("跑三轮", "", ""))
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "done" {
t.Fatalf("响应=%q期望 done", resp)
}
if len(toolsUsed) != 3 || len(toolResults) != 3 {
t.Fatalf("toolsUsed=%d toolResults=%d期望各 3", len(toolsUsed), len(toolResults))
}
// 步数护栏未触发(触发了会是 "step budget exhausted" 错误)。
if len(sp.reqs) != 4 {
t.Fatalf("LLM 调用次数=%d期望 4", len(sp.reqs))
}
}
// 中断interceptCh在工具批中途到达时本批**剩余工具被放弃**,直接进入下一轮。
//
// 这是原实现的 `break` 语义process.go 旧版工具循环尾部),必须保持。
func TestTaskFrame_InterruptAbandonsRemainingBatch(t *testing.T) {
sh := NewStageHost()
var a *Agent
sh.RegisterTool("t_first", sdk.ToolDef{Name: "t_first", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
// 工具执行期间产生一次中断(模拟插件在工具里注入打断)。
a.interceptCh <- &agentIO.InputEvent{Source: "t", Payload: map[string]interface{}{"content": "新的用户输入"}}
return "first-out", nil
})
sh.RegisterTool("t_second", sdk.ToolDef{Name: "t_second", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
t.Fatal("批内第二个工具不应被执行:中断必须放弃剩余批次")
return nil, nil
})
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_first"), tc("c2", "t_second")}},
{Content: "处理完中断后的答复"},
}}
a = newTaskTestAgent(t, sp, sh)
resp, toolsUsed, toolResults, err := a.process("开始", a.stageCtxFromInput("开始", "", ""))
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "处理完中断后的答复" {
t.Fatalf("响应=%q", resp)
}
if len(toolsUsed) != 1 || toolsUsed[0] != "t_first" {
t.Fatalf("toolsUsed=%v期望只有 t_first", toolsUsed)
}
if len(toolResults) != 1 || toolResults[0].Output != "first-out" {
t.Fatalf("toolResults=%+v期望只有 t_first 的结果", toolResults)
}
// 第二轮请求里必须出现被打断内容system 角色、[中断消息] 前缀)。
found := false
for _, m := range sp.reqs[1].Messages {
if m.Role == "system" && len(m.Content) > 0 && m.Content[0:len("[中断消息]")] == "[中断消息]" {
found = true
}
}
if !found {
t.Fatalf("第二轮请求缺少 [中断消息] system 消息")
}
}
// 状态机对未知 step 必须失败退出而不是空转。
func TestTaskFrame_UnknownStepFails(t *testing.T) {
sp := &scriptProvider{}
a := newTaskTestAgent(t, sp, NewStageHost())
f := a.newTaskFrame("x", a.stageCtxFromInput("x", "", ""))
f.Step = Step(999)
if out := a.step(f); out != outcomeFailed {
t.Fatalf("未知 step 应返回 outcomeFailed实际 %v", out)
}
if f.Err == nil {
t.Fatal("未知 step 必须带错误信息")
}
}