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)
231 lines
8.1 KiB
Go
231 lines
8.1 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
|
||
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)
|
||
}
|
||
}
|