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)。
327 lines
12 KiB
Go
327 lines
12 KiB
Go
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
|
||
}
|