Files
Agent-System-Backend/internal/runtime/llm_test.go
T
weichen 53c6630eca Add submit_card explicit routing with card-as-authority and context backfill (CF1)
Why: 卡流机制(D39–D43)——产物型长内容显式路由进收件箱:模型经 submit_card 提交 result 卡,全文权威在卡片,消息只存引用,上下文回填防失忆。
Evidence: go test ./internal/runtime/(门控/产卡/引用部件)、./internal/ctxasm/(回填/截断/占位)、./internal/server/ 全绿(新增 TestMockCardflowFullLoop:#cardflow → result 卡 + cardId 部件 + finishRun 锚点回填)。
Impact: 新内置工具 submit_card(ui.cardflow 默认关闭,未声明被幻觉调用时拒绝执行);settings 增 ui.cardflow;style-cardflow 模块 + ModuleBody 注入;ctxasm 增 CardResolver 与 card(n) 观测;runmgr 增 SubmitCard/Card 与锚点回填;mock 增 #cardflow。
Refs: Dev 任务卡 feature/cardflow-r1;依赖 core bcfacee(CardPart.CardID)。
2026-09-30 16:30:13 +00:00

327 lines
12 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 runtime
import (
"context"
"fmt"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
type emittedEvent struct {
typ core.StreamEventType
payload any
}
// fakeRunCtx:RunCtx 最小实现——只收集事件与终态部件,其余 no-op。
type fakeRunCtx struct {
ctx context.Context
prof core.AgentProfile
cardflow bool
events []emittedEvent
assistant []core.Part
cards []core.ActivityCard
toolDecls []agentllm.ToolDecl
execArtifact *core.Artifact // ExecuteTool 返回的工件(非 nil 时走工件路径)
}
func (f *fakeRunCtx) Context() context.Context { return f.ctx }
func (f *fakeRunCtx) Run() core.Run { return core.Run{ID: "run_t"} }
func (f *fakeRunCtx) Profile() core.AgentProfile { return f.prof }
func (f *fakeRunCtx) Settings() core.EffectiveSettings {
eff := core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{"baseUrl": "http://test"}}}
if f.cardflow {
eff["ui"] = map[string]any{"cardflow": true}
}
return eff
}
func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
f.events = append(f.events, emittedEvent{typ, payload})
return nil
}
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
func (f *fakeRunCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
return core.HITLResponse{}, fmt.Errorf("not implemented")
}
func (f *fakeRunCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
return core.AgentResultPayload{}, fmt.Errorf("not implemented")
}
func (f *fakeRunCtx) UpdatePlan(p core.PlanSnapshot) error { return nil }
func (f *fakeRunCtx) UpdateTodos(l core.TodoList) error { return nil }
func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
return core.Artifact{}, fmt.Errorf("not implemented")
}
func (f *fakeRunCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
res := core.ToolCallResult{Status: "ok", Output: "done"}
if f.execArtifact != nil {
res.Artifact = f.execArtifact
}
return res
}
func (f *fakeRunCtx) History() []core.Message {
return []core.Message{{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}}
}
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil }
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
func (f *fakeRunCtx) Subtask() string { return "" }
func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
card.ID = core.ID(fmt.Sprintf("card_%d", len(f.cards)+1))
f.cards = append(f.cards, card)
return card, nil
}
func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) {
return f.CreateCard(card)
}
func (f *fakeRunCtx) Card(id string) *core.ActivityCard {
for i := range f.cards {
if string(f.cards[i].ID) == id {
return &f.cards[i]
}
}
return nil
}
func (f *fakeRunCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error {
return nil
}
func (f *fakeRunCtx) SetContextObservation(o core.ContextObservation) {}
// scriptStream:每次调用回放一轮(真实客户端语义:一个 message_end 后关通道)。
// 轮数耗尽后再调用返回空流(防御;正常终轮后 runner 不再调用)。
// declSink 非空时捕获一次工具声明清单(断言 submit_card 门控用)。
func scriptStream(turns [][]agentllm.Event, declSink *[]agentllm.ToolDecl) agentllm.StreamFunc {
n := 0
return func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
if declSink != nil && *declSink == nil {
*declSink = tools
}
out := make(chan agentllm.Event)
var evs []agentllm.Event
if n < len(turns) {
evs = turns[n]
n++
}
go func() {
defer close(out)
for _, e := range evs {
select {
case out <- e:
case <-ctx.Done():
return
}
}
}()
return out
}
}
// TestLLMRunnerToolTurnTextIsProcess 验证:工具轮的文本是过程解说(Process=true),
// 终轮文本(无工具调用)才是对用户的回复(无标记)——中栏过程/回复分离的数据基础。
func TestLLMRunnerToolTurnTextIsProcess(t *testing.T) {
turns := [][]agentllm.Event{
{ // 第 1 轮:思考 + 解说文本 + 工具调用。
agentllm.Event{Kind: agentllm.KindReasoningDelta, ReasoningDelta: "先想想"},
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "我先更新待办。"},
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
{ID: "c1", Name: "update_todos", Arguments: "{}"},
}}},
},
{ // 第 2 轮(终轮):最终回复文本。
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "完成了。"},
agentllm.Event{Kind: agentllm.KindMessageEnd},
},
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
type want struct {
typ core.PartType
text string
process bool
}
got := make([]want, 0, len(rc.assistant))
for _, p := range rc.assistant {
got = append(got, want{p.Type, p.Text, p.Process})
}
expected := []want{
{core.PartThinking, "先想想", true},
{core.PartText, "我先更新待办。", true}, // 工具轮文本 = 过程
{core.PartToolCall, "", true},
{core.PartToolResult, "", true},
{core.PartText, "完成了。", false}, // 终轮文本 = 回复
}
if len(got) != len(expected) {
t.Fatalf("parts 数量不符:got %d (%+v) want %d", len(got), got, len(expected))
}
for i, g := range got {
if g != expected[i] {
t.Errorf("parts[%d] = %+v, want %+v", i, g, expected[i])
}
}
}
// TestLLMRunnerPhaseEventsPerTurn 验证:每轮首个文本增量发 responding、工具轮开始发
// acting——前端「响应中折叠过程」的驱动信号(交错轮不粘滞)。
func TestLLMRunnerPhaseEventsPerTurn(t *testing.T) {
turns := [][]agentllm.Event{
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "解说"},
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
{ID: "c1", Name: "update_todos"},
}}},
},
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "最终回复"},
agentllm.Event{Kind: agentllm.KindMessageEnd},
},
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
var phases []string
for _, e := range rc.events {
if e.typ == core.EvRunPhase {
p, ok := e.payload.(core.RunPhasePayload)
if !ok {
t.Fatalf("run.phase payload 类型错误: %T", e.payload)
}
phases = append(phases, string(p.Phase))
}
}
// 第 1 轮 responding(文本增量)→ acting(工具轮);第 2 轮再次 responding。
wantPhases := []string{"responding", "acting", "responding"}
if len(phases) != len(wantPhases) {
t.Fatalf("phase 序列 = %v, want %v", phases, wantPhases)
}
for i := range phases {
if phases[i] != wantPhases[i] {
t.Fatalf("phase 序列 = %v, want %v", phases, wantPhases)
}
}
}
// TestLLMRunnerArtifactPartIsProcess 验证:工具产生的工件引用部件同样标记为过程,
// 终态随过程折叠(不残留在回复区)。
func TestLLMRunnerArtifactPartIsProcess(t *testing.T) {
turns := [][]agentllm.Event{
{ // 第 1 轮:解说文本 + 工具调用(产生工件)。
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "我生成一份报表。"},
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
{ID: "c1", Name: "make_report", Arguments: "{}"},
}}},
},
{ // 第 2 轮(终轮):最终回复文本。
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "见上方工件。"},
agentllm.Event{Kind: agentllm.KindMessageEnd},
},
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
execArtifact: &core.Artifact{ID: "a1", Name: "report.csv", Kind: "csv"},
}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
found := false
for _, p := range rc.assistant {
if p.Type != core.PartArtifactRef {
continue
}
found = true
if !p.Process {
t.Errorf("artifact_ref 部件未标记 Process: %+v", p)
}
if p.Artifact == nil || p.Artifact.ArtifactID != "a1" {
t.Errorf("artifact 引用不符: %+v", p.Artifact)
}
}
if !found {
t.Fatalf("未找到 artifact_ref 部件: %+v", rc.assistant)
}
}
// TestLLMRunnerSubmitCardGatedBySetting 验证 D39/D42:ui.cardflow 开启时 submit_card
// 才声明并产 result 卡 + 引用部件;关闭时工具不声明、不产卡(行为等价现状)。
func TestLLMRunnerSubmitCardGatedBySetting(t *testing.T) {
cardArgs := `{"title":"会议纪要","summary":"三项决议","body":"# 会议纪要\n全文……"}`
turnsWithCard := [][]agentllm.Event{
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "已完成,详见卡片。"},
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
{ID: "c1", Name: "submit_card", Arguments: cardArgs},
}}},
},
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "要点如上。"},
agentllm.Event{Kind: agentllm.KindMessageEnd},
},
}
// 开启:声明工具、产卡、部件带 cardId。
var declsOn []agentllm.ToolDecl
r := &LLMRunner{Stream: scriptStream(turnsWithCard, &declsOn)}
rcOn := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true}
if err := r.Execute(rcOn); err != nil {
t.Fatalf("Execute(cardflow on): %v", err)
}
if !hasToolDecl(declsOn, "submit_card") {
t.Error("submit_card 应在开启时声明")
}
if len(rcOn.cards) != 1 {
t.Fatalf("开启时应恰好产 1 张卡,got %d", len(rcOn.cards))
}
if rcOn.cards[0].Kind != core.CardKindResult || rcOn.cards[0].Status != core.CardDone {
t.Errorf("卡 kind/status = %s/%s, want result/done", rcOn.cards[0].Kind, rcOn.cards[0].Status)
}
var refPart *core.Part
for i := range rcOn.assistant {
if rcOn.assistant[i].Type == core.PartCard {
refPart = &rcOn.assistant[i]
}
}
if refPart == nil || refPart.Card == nil || string(refPart.Card.CardID) != string(rcOn.cards[0].ID) {
t.Errorf("消息应含 cardId=%s 的引用部件", rcOn.cards[0].ID)
}
// 关闭:不声明、不产卡(调用落入注册表 default 路径,fake 返回 ok 但无卡)。
var declsOff []agentllm.ToolDecl
r2 := &LLMRunner{Stream: scriptStream(turnsWithCard, &declsOff)}
rcOff := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
if err := r2.Execute(rcOff); err != nil {
t.Fatalf("Execute(cardflow off): %v", err)
}
if hasToolDecl(declsOff, "submit_card") {
t.Error("submit_card 不应在关闭时声明")
}
if len(rcOff.cards) != 0 {
t.Errorf("关闭时不应产卡,got %d", len(rcOff.cards))
}
for _, p := range rcOff.assistant {
if p.Type == core.PartCard {
t.Error("关闭时消息不应含卡片部件")
}
}
}
func hasToolDecl(decls []agentllm.ToolDecl, name string) bool {
for _, d := range decls {
if d.Name == name {
return true
}
}
return false
}