Files
Agent-System-Backend/internal/runtime/llm_test.go
T
weichen ae8e5d0007 Mark artifact reference parts as process output
Why: tool-produced artifact_ref parts were appended without Process,
unlike the tool_call/tool_result parts of the same turn, so on final
reconcile the artifact chip stayed in the reply area instead of folding
into the process summary.

Evidence: llm.go now sets Process: true on PartArtifactRef next to its
tool parts; new TestLLMRunnerArtifactPartIsProcess drives a tool turn
that produces an artifact and asserts the part exists, is marked, and
carries the artifact ref. go test ./... all packages ok.

Impact: persisted assistant messages fold artifact references into the
collapsed process area, consistent with other tool-turn output.

Refs: code review of feature/chat-process-split-r1 (2026-09-30)
2026-09-30 15:33:48 +00:00

231 lines
8.1 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
events []emittedEvent
assistant []core.Part
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 {
return core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{"baseUrl": "http://test"}}}
}
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) {
return card, 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 不再调用)。
func scriptStream(turns [][]agentllm.Event) agentllm.StreamFunc {
n := 0
return func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
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)}
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)}
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)}
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)
}
}