1135 lines
41 KiB
Go
1135 lines
41 KiB
Go
package runtime
|
||
|
||
import (
|
||
"context"
|
||
|
||
agentmemory "agent-native/runtime/memory"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
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
|
||
eventsMu sync.Mutex
|
||
assistant []core.Part
|
||
cards []core.ActivityCard
|
||
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
|
||
projectID string // Run().ProjectID(scope=project 断言用)
|
||
toolDecls []agentllm.ToolDecl
|
||
execArtifact *core.Artifact // ExecuteTool 返回的工件(非 nil 时走工件路径)
|
||
execTruncated bool // ExecuteTool 返回的截断标记(门面卡触发)
|
||
toolRuns int // ExecuteTool 被调用的次数
|
||
execArtifactContent []byte // Artifact(id) 回填内容(ctxasm D46 断言用)
|
||
verify bool // runtime.verify;缺省关闭,避免改变既有流调用次数
|
||
compose bool // runtime.composeFinal;缺省关闭,避免多一次主模型调用
|
||
missingArtifact bool // 工具登记了工件,但 Artifact 查不到
|
||
emptyArtifact bool // Artifact 查到了,内容为空
|
||
history []core.Message // 非 nil 时覆盖默认历史
|
||
bgID string
|
||
bgErr error
|
||
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
|
||
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用)
|
||
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
|
||
bgCalls int
|
||
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
|
||
attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用)
|
||
spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||
execStatus string // 非空时覆盖 ExecuteTool 状态(R4 失败链测试)
|
||
execOutput string // 非空时覆盖 ExecuteTool 输出(空命中/账本测试)
|
||
}
|
||
|
||
func (f *fakeRunCtx) Context() context.Context { return f.ctx }
|
||
func (f *fakeRunCtx) Run() core.Run { return core.Run{ID: "run_t", ProjectID: core.ID(f.projectID)} }
|
||
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}
|
||
}
|
||
runtimeMap := map[string]any{"verify": f.verify, "composeFinal": f.compose}
|
||
for k, v := range f.runtimeExtra {
|
||
runtimeMap[k] = v
|
||
}
|
||
eff["runtime"] = runtimeMap
|
||
return eff
|
||
}
|
||
func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
|
||
f.eventsMu.Lock()
|
||
defer f.eventsMu.Unlock()
|
||
f.events = append(f.events, emittedEvent{typ, payload})
|
||
return nil
|
||
}
|
||
func (f *fakeRunCtx) emittedEvents() []emittedEvent {
|
||
f.eventsMu.Lock()
|
||
defer f.eventsMu.Unlock()
|
||
return append([]emittedEvent(nil), f.events...)
|
||
}
|
||
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
|
||
func (f *fakeRunCtx) AddUsage(u core.Usage) {
|
||
f.usageTotal.PromptTokens += u.PromptTokens
|
||
f.usageTotal.CompletionTokens += u.CompletionTokens
|
||
f.usageTotal.TotalTokens += u.TotalTokens
|
||
f.usageTotal.ToolCalls += u.ToolCalls
|
||
f.usageTotal.LastPromptTokens = u.LastPromptTokens
|
||
f.usageTotal.LastCompletionTokens = u.LastCompletionTokens
|
||
f.usageTotal.CacheReadTokens += u.CacheReadTokens
|
||
f.usageTotal.CacheWriteTokens += u.CacheWriteTokens
|
||
}
|
||
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")
|
||
}
|
||
|
||
// SpawnAgent 供 delegate_task 直连分支(E1/F1 测试注入);未设置时与
|
||
// SpawnSubagent 一致报错。
|
||
func (f *fakeRunCtx) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
||
if f.spawnAgentFn != nil {
|
||
return f.spawnAgentFn(agentID, name, task, hint)
|
||
}
|
||
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 {
|
||
f.toolRuns++
|
||
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
|
||
if f.execStatus != "" {
|
||
res.Status = f.execStatus
|
||
}
|
||
if f.execOutput != "" {
|
||
res.Output = f.execOutput
|
||
}
|
||
if f.execArtifact != nil {
|
||
res.Artifact = f.execArtifact
|
||
}
|
||
return res
|
||
}
|
||
func (f *fakeRunCtx) History() []core.Message {
|
||
if f.history != nil {
|
||
return f.history
|
||
}
|
||
return []core.Message{{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}}
|
||
}
|
||
func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID, error) {
|
||
f.bgCalls++
|
||
if f.bgErr != nil {
|
||
return "", f.bgErr
|
||
}
|
||
if f.bgID == "" {
|
||
f.bgID = "run_bg"
|
||
}
|
||
return f.bgID, nil
|
||
}
|
||
func (f *fakeRunCtx) ConversationSandbox() string {
|
||
if f.sandbox != "" {
|
||
return f.sandbox
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// memEntry 是测试用记忆条目(避免 fake 引入库类型)。
|
||
type memEntry struct{ typ, content string }
|
||
|
||
type fakeMemStore struct{ entries []memEntry }
|
||
|
||
func (s fakeMemStore) Recall(_ context.Context, q agentmemory.RecallQuery) ([]agentmemory.Entry, error) {
|
||
out := make([]agentmemory.Entry, 0, len(s.entries))
|
||
for _, e := range s.entries {
|
||
out = append(out, agentmemory.Entry{Type: e.typ, Content: e.content})
|
||
}
|
||
return out, nil
|
||
}
|
||
func (s fakeMemStore) Append(_ context.Context, e agentmemory.Entry) error { return nil }
|
||
|
||
func (f *fakeRunCtx) StoreMemory() agentmemory.Store {
|
||
if f.memEntries == nil {
|
||
return nil
|
||
}
|
||
return fakeMemStore{entries: f.memEntries}
|
||
}
|
||
|
||
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return f.attachments }
|
||
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) Artifact(id string) (core.Artifact, []byte, bool) {
|
||
if f.execArtifact != nil && f.execArtifact.ID == id {
|
||
if f.missingArtifact {
|
||
return core.Artifact{}, nil, false
|
||
}
|
||
if f.emptyArtifact {
|
||
return *f.execArtifact, nil, true
|
||
}
|
||
return *f.execArtifact, f.execArtifactContent, true
|
||
}
|
||
return core.Artifact{}, nil, false
|
||
}
|
||
func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||
return f.CreateCard(card)
|
||
}
|
||
|
||
// 流式三段式(D44/D45):fake 侧以 streamCardID 指针 + 尾部 markdown 合并模拟
|
||
// runmgr/cardsvc 语义,供工具编排层断言。
|
||
func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||
card.Status = core.CardActive
|
||
created, err := f.CreateCard(card)
|
||
if err != nil {
|
||
return created, err
|
||
}
|
||
f.streamCardID = created.ID
|
||
return created, nil
|
||
}
|
||
|
||
func (f *fakeRunCtx) AppendStreamCard(text string) error {
|
||
if f.streamCardID == "" {
|
||
return errors.New("card: no active stream card (card_begin first)")
|
||
}
|
||
for i := range f.cards {
|
||
if f.cards[i].ID != f.streamCardID {
|
||
continue
|
||
}
|
||
if n := len(f.cards[i].Body); n > 0 && f.cards[i].Body[n-1].Type == core.UIMarkdown {
|
||
f.cards[i].Body[n-1].Markdown += text
|
||
} else {
|
||
f.cards[i].Body = append(f.cards[i].Body, core.UINode{Type: core.UIMarkdown, Markdown: text})
|
||
}
|
||
return nil
|
||
}
|
||
return errors.New("card: not found")
|
||
}
|
||
|
||
func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||
if f.streamCardID == "" {
|
||
return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)")
|
||
}
|
||
id := f.streamCardID
|
||
f.streamCardID = ""
|
||
for i := range f.cards {
|
||
if f.cards[i].ID == id {
|
||
f.cards[i].Status = core.CardDone
|
||
return f.cards[i], nil
|
||
}
|
||
}
|
||
return core.ActivityCard{}, errors.New("card: not found")
|
||
}
|
||
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)
|
||
}
|
||
if rc.usageTotal.ToolCalls != 1 {
|
||
t.Fatalf("direct tool call not reflected in run usage: %+v", rc.usageTotal)
|
||
}
|
||
|
||
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.emittedEvents() {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerDeliverablePinnedAfterText 验证:工具产生的工件引用作为内容部件
|
||
// (不进过程折叠区),且钉在最终正文之后——对话内直接给出交付物卡片。
|
||
func TestLLMRunnerDeliverablePinnedAfterText(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)
|
||
}
|
||
|
||
refIdx, lastTextIdx := -1, -1
|
||
for i, p := range rc.assistant {
|
||
if p.Type == core.PartText {
|
||
lastTextIdx = i
|
||
}
|
||
if p.Type == core.PartArtifactRef {
|
||
if refIdx != -1 {
|
||
t.Errorf("artifact_ref 部件重复: %+v", rc.assistant)
|
||
}
|
||
refIdx = i
|
||
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 refIdx == -1 {
|
||
t.Fatalf("未找到 artifact_ref 部件: %+v", rc.assistant)
|
||
}
|
||
if lastTextIdx == -1 || refIdx < lastTextIdx {
|
||
t.Errorf("artifact_ref 应钉在最终正文之后: refIdx=%d lastTextIdx=%d parts=%+v", refIdx, lastTextIdx, 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
|
||
}
|
||
|
||
// TestLLMRunnerCardStreamTools 验证 D44/D45:cardflow 开启时三段式工具声明,
|
||
// begin→append×N→commit 编排为一张 done 产物卡(正文逐段合并)+ cardId 引用
|
||
// 部件;commit 后活跃指针清空,再 append 被拒绝。
|
||
func TestLLMRunnerCardStreamTools(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: "card_begin", Arguments: `{"title":"项目周报","summary":"流式演示"}`},
|
||
}}},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
|
||
{ID: "c2", Name: "card_append", Arguments: `{"text":"## 进展\n- 全绿\n"}`},
|
||
}}},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
|
||
{ID: "c3", Name: "card_append", Arguments: `{"text":"## 风险\n- 无\n"}`},
|
||
{ID: "c4", Name: "card_commit", Arguments: "{}"},
|
||
}}},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "周报完成,详见卡片。"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
}
|
||
var declsOn []agentllm.ToolDecl
|
||
r := &LLMRunner{Stream: scriptStream(turns, &declsOn)}
|
||
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true}
|
||
if err := r.Execute(rc); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
for _, n := range []string{"card_begin", "card_append", "card_commit", "submit_card"} {
|
||
if !hasToolDecl(declsOn, n) {
|
||
t.Errorf("%s 应在 ui.cardflow 开启时声明", n)
|
||
}
|
||
}
|
||
if len(rc.cards) != 1 {
|
||
t.Fatalf("应恰好产 1 张流式卡,got %d", len(rc.cards))
|
||
}
|
||
card := rc.cards[0]
|
||
if card.Status != core.CardDone || card.Kind != core.CardKindResult {
|
||
t.Errorf("卡 status/kind = %s/%s, want done/result", card.Status, card.Kind)
|
||
}
|
||
full := ""
|
||
for _, n := range card.Body {
|
||
full += n.Markdown
|
||
}
|
||
if !strings.Contains(full, "## 进展") || !strings.Contains(full, "## 风险") {
|
||
t.Errorf("流式正文未逐段合并齐全: %q", full)
|
||
}
|
||
var refPart *core.Part
|
||
for i := range rc.assistant {
|
||
if rc.assistant[i].Type == core.PartCard {
|
||
refPart = &rc.assistant[i]
|
||
}
|
||
}
|
||
if refPart == nil || refPart.Card == nil || string(refPart.Card.CardID) != string(card.ID) {
|
||
t.Errorf("消息应含 cardId=%s 的引用部件", card.ID)
|
||
}
|
||
if err := rc.AppendStreamCard("late"); err == nil {
|
||
t.Error("commit 后 append 应报无活跃卡")
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerCardStreamGated 验证:ui.cardflow 关闭时三工具不声明;幻觉调用
|
||
// begin 被拒绝且不产卡。
|
||
func TestLLMRunnerCardStreamGated(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
|
||
{ID: "c1", Name: "card_begin", Arguments: `{"title":"越权卡"}`},
|
||
}}},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "好的。"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
}
|
||
var declsOff []agentllm.ToolDecl
|
||
r := &LLMRunner{Stream: scriptStream(turns, &declsOff)}
|
||
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)
|
||
}
|
||
for _, n := range []string{"card_begin", "card_append", "card_commit"} {
|
||
if hasToolDecl(declsOff, n) {
|
||
t.Errorf("%s 不应在关闭时声明", n)
|
||
}
|
||
}
|
||
if len(rc.cards) != 0 {
|
||
t.Errorf("关闭时幻觉 begin 不应产卡,got %d", len(rc.cards))
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerArtifactFacadeCard 验证 D47:截断的工具大结果自动产门面卡
|
||
// (result/done,含来源 kv 与 doc-ref),消息同时携带 card 引用与 artifact_ref;
|
||
// 未截断的结果不建卡(防收件箱噪音)。
|
||
func TestLLMRunnerArtifactFacadeCard(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: "big_search", Arguments: `{"query":"报告"}`},
|
||
}}},
|
||
},
|
||
{
|
||
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"}}, cardflow: true,
|
||
execTruncated: true,
|
||
execArtifact: &core.Artifact{ID: "art_1", Name: "大结果.md", Kind: "document"},
|
||
}
|
||
if err := r.Execute(rc); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if len(rc.cards) != 1 {
|
||
t.Fatalf("截断结果应产 1 张门面卡,got %d", len(rc.cards))
|
||
}
|
||
fc := rc.cards[0]
|
||
if fc.Kind != core.CardKindResult || fc.Status != core.CardDone || fc.Title != "工具大结果 · 大结果.md" {
|
||
t.Errorf("门面卡 kind/status/title = %s/%s/%q", fc.Kind, fc.Status, fc.Title)
|
||
}
|
||
hasKV, hasDocRef := false, false
|
||
for _, n := range fc.Body {
|
||
if n.Type == "kv" {
|
||
hasKV = true
|
||
}
|
||
if n.Type == "doc-ref" && n.DocID == "art_1" {
|
||
hasDocRef = true
|
||
}
|
||
}
|
||
if !hasKV || !hasDocRef {
|
||
t.Errorf("门面卡 body 缺 kv/doc-ref: %+v", fc.Body)
|
||
}
|
||
hasRef, hasArt := false, false
|
||
for _, p := range rc.assistant {
|
||
if p.Type == core.PartCard && p.Card != nil && p.Card.CardID == fc.ID {
|
||
hasRef = true
|
||
}
|
||
if p.Type == core.PartArtifactRef && p.Artifact != nil && p.Artifact.ArtifactID == "art_1" {
|
||
hasArt = true
|
||
}
|
||
}
|
||
if !hasRef || !hasArt {
|
||
t.Errorf("消息应同时含门面卡引用与 artifact_ref(ref=%v art=%v)", hasRef, hasArt)
|
||
}
|
||
|
||
// 未截断:不建卡。
|
||
rc2 := &fakeRunCtx{
|
||
ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true,
|
||
execArtifact: &core.Artifact{ID: "art_2", Name: "小结果.md", Kind: "document"},
|
||
}
|
||
r2 := &LLMRunner{Stream: scriptStream(turns, nil)}
|
||
if err := r2.Execute(rc2); err != nil {
|
||
t.Fatalf("Execute(not truncated): %v", err)
|
||
}
|
||
if len(rc2.cards) != 0 {
|
||
t.Errorf("未截断结果不应建门面卡,got %d", len(rc2.cards))
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerArtifactFacadeSuppressed 验证 D47 防双卡:本 run 已显式
|
||
// submit_card 时,截断的工具大结果不再自动建门面卡。
|
||
func TestLLMRunnerArtifactFacadeSuppressed(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
|
||
{ID: "c1", Name: "submit_card", Arguments: `{"title":"正式产物","summary":"摘要","body":"# 正文"}`},
|
||
}}},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
|
||
{ID: "c2", Name: "big_search", Arguments: `{"query":"x"}`},
|
||
}}},
|
||
},
|
||
{
|
||
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"}}, cardflow: true,
|
||
execTruncated: true,
|
||
execArtifact: &core.Artifact{ID: "art_9", Name: "附属.md", Kind: "document"},
|
||
}
|
||
if err := r.Execute(rc); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if len(rc.cards) != 1 || rc.cards[0].Title != "正式产物" {
|
||
t.Errorf("显式卡存在时不应有门面卡,got %+v", rc.cards)
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerCardScopeProject 验证 D48:submit_card scope=project 产出项目级卡
|
||
// (ProjectID 预置、ConversationID 留空),归属由 runmgr CreateCard 保持。
|
||
func TestLLMRunnerCardScopeProject(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
|
||
{ID: "c1", Name: "submit_card", Arguments: `{"title":"项目产物","summary":"跨会话","body":"# 正文","scope":"project"}`},
|
||
}}},
|
||
},
|
||
{
|
||
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"}}, cardflow: true, projectID: "prj_9"}
|
||
if err := r.Execute(rc); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if len(rc.cards) != 1 {
|
||
t.Fatalf("应产 1 张卡,got %d", len(rc.cards))
|
||
}
|
||
if rc.cards[0].ProjectID != "prj_9" {
|
||
t.Errorf("scope=project 应预置 ProjectID, got %q", rc.cards[0].ProjectID)
|
||
}
|
||
if rc.cards[0].ConversationID != "" {
|
||
t.Errorf("项目级卡 ConversationID 应留空, got %q", rc.cards[0].ConversationID)
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerRecordsThinkingAndToolDuration 验证:思考部件带上推理增量的时长,
|
||
// 工具调用带上执行时长,成功的工具结果也留下摘要(刷新后仍能展开)。
|
||
func TestLLMRunnerRecordsThinkingAndToolDuration(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindReasoningDelta, ReasoningDelta: "先"},
|
||
agentllm.Event{Kind: agentllm.KindReasoningDelta, ReasoningDelta: "想想"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
|
||
{ID: "c1", Name: "update_todos", Arguments: "{}"},
|
||
}}},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "完成了。"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
}
|
||
var tick int64
|
||
r := &LLMRunner{
|
||
Stream: scriptStream(turns, nil),
|
||
Now: func() time.Time {
|
||
tick++
|
||
return time.Unix(0, tick*int64(time.Millisecond))
|
||
},
|
||
}
|
||
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 thinking *core.Part
|
||
var call *core.Part
|
||
var result *core.Part
|
||
for i := range rc.assistant {
|
||
p := &rc.assistant[i]
|
||
switch p.Type {
|
||
case core.PartThinking:
|
||
thinking = p
|
||
case core.PartToolCall:
|
||
call = p
|
||
case core.PartToolResult:
|
||
result = p
|
||
}
|
||
}
|
||
if thinking == nil || thinking.DurationMs != 1 {
|
||
t.Fatalf("thinking durationMs = %v, want 1 (two reasoning deltas, 1ms apart)", thinking)
|
||
}
|
||
if call == nil || call.ToolCall == nil || call.ToolCall.Duration != 1 {
|
||
t.Fatalf("tool durationMs = %+v, want 1", call)
|
||
}
|
||
if result == nil || result.ToolResult == nil || result.ToolResult.Summary != "待办已更新" {
|
||
t.Fatalf("success summary = %+v, want 待办已更新", result)
|
||
}
|
||
}
|
||
|
||
func textParts(parts []core.Part) []core.Part {
|
||
var out []core.Part
|
||
for _, p := range parts {
|
||
if p.Type == core.PartText {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestLLMRunnerFalseDeliverableGuard 验证:终轮声称已生成文件但没有工件时,
|
||
// 这段正文降为过程,纠正一次;下一轮如实说明则作为最终回复。
|
||
func TestLLMRunnerFalseDeliverableGuard(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文档已生成:\n\n研发智能体方案评审_冲突点分析.docx\n工件 ID:art_4f9d2c1a8b3e"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "这一轮没有生成文件。"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
}
|
||
var calls int
|
||
var lastUser string
|
||
base := scriptStream(turns, nil)
|
||
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||
calls++
|
||
if n := len(msgs); n > 0 && msgs[n-1].Role == agentllm.RoleUser {
|
||
lastUser = msgs[n-1].Content
|
||
}
|
||
return base(ctx, model, msgs, tools, opts)
|
||
}}
|
||
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)
|
||
}
|
||
if calls != 2 {
|
||
t.Fatalf("stream calls = %d, want 2", calls)
|
||
}
|
||
if !strings.Contains(lastUser, "register_artifact") {
|
||
t.Fatalf("correction missing, got %q", lastUser)
|
||
}
|
||
texts := textParts(rc.assistant)
|
||
if len(texts) != 2 || !texts[0].Process || texts[1].Process {
|
||
t.Fatalf("text parts = %+v, want claimed text process then honest reply", texts)
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerForgedToolMarkerTriggersGuard 验证:正文里的伪造调用标记也会触发守卫,
|
||
// 即使没有工件编号和扩展名。回灌给模型的是占位,不是伪造正文。
|
||
func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "好的。\n[调用工具 run_python({\"script\":\"print(1)\"})]\n[工具结果 ok 完成]"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "这一轮没有生成文件。"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
}
|
||
var calls int
|
||
var replay []agentllm.Message
|
||
base := scriptStream(turns, nil)
|
||
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||
calls++
|
||
if calls == 2 {
|
||
replay = append([]agentllm.Message(nil), msgs...)
|
||
}
|
||
return base(ctx, model, msgs, tools, opts)
|
||
}}
|
||
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)
|
||
}
|
||
if calls != 2 {
|
||
t.Fatalf("stream calls = %d, want 2", calls)
|
||
}
|
||
// N6(N3 WP1):伪造正文不得作为 assistant 的话术回放;更正以 user
|
||
// 消息出现(不再改写 assistant)。
|
||
for _, m := range replay {
|
||
if m.Role != agentllm.RoleAssistant {
|
||
continue
|
||
}
|
||
if strings.Contains(m.Content, "run_python") {
|
||
t.Fatalf("forged body was replayed: %q", m.Content)
|
||
}
|
||
}
|
||
var correction bool
|
||
for _, m := range replay {
|
||
if m.Role == agentllm.RoleUser && strings.Contains(m.Content, "已作废") {
|
||
correction = true
|
||
}
|
||
}
|
||
if !correction {
|
||
t.Fatalf("correction replay missing user note, got %#v", replay)
|
||
}
|
||
texts := textParts(rc.assistant)
|
||
if len(texts) != 2 || !texts[0].Process || texts[1].Process {
|
||
t.Fatalf("text parts = %+v, want forged text process then honest reply", texts)
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerUndeclaredFileClaimTriggersGuard 验证:不带扩展名的「文件已生成/登记」也算谎称。
|
||
func TestLLMRunnerUndeclaredFileClaimTriggersGuard(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文件已正常生成并登记。工件卡片应该出现在对话下方。"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "这一轮没有生成文件。"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
}
|
||
var calls int
|
||
base := scriptStream(turns, nil)
|
||
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||
calls++
|
||
return base(ctx, model, msgs, tools, opts)
|
||
}}
|
||
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)
|
||
}
|
||
if calls != 2 {
|
||
t.Fatalf("stream calls = %d, want 2", calls)
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerFalseDeliverableGuardTwiceThenNotice 验证:纠正两次后仍谎称,
|
||
// 不再请求模型,终轮正文降为过程,并追加一条非过程的系统提示。
|
||
func TestLLMRunnerFalseDeliverableGuardTwiceThenNotice(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文档已生成:甲.docx"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文档已保存:乙.docx"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文档已导出:丙.docx"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
}
|
||
var calls int
|
||
base := scriptStream(turns, nil)
|
||
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||
calls++
|
||
return base(ctx, model, msgs, tools, opts)
|
||
}}
|
||
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)
|
||
}
|
||
if calls != 3 {
|
||
t.Fatalf("stream calls = %d, want 3", calls)
|
||
}
|
||
texts := textParts(rc.assistant)
|
||
if len(texts) != 4 {
|
||
t.Fatalf("text parts = %d, want 3 demoted claims plus a notice", len(texts))
|
||
}
|
||
for i := 0; i < 3; i++ {
|
||
if !texts[i].Process {
|
||
t.Fatalf("claim %d should be process, got %+v", i, texts[i])
|
||
}
|
||
}
|
||
if texts[3].Process || !strings.Contains(texts[3].Text, "本轮未实际生成文件") {
|
||
t.Fatalf("notice = %+v, want a non-process fallback", texts[3])
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerExistingArtifactCitationNotGuarded 验证:引用项目里已有的工件不算谎称。
|
||
func TestLLMRunnerExistingArtifactCitationNotGuarded(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "上次已生成的报告.docx 在这里:art_aaaaaaaaaaaa"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||
},
|
||
}
|
||
var calls int
|
||
base := scriptStream(turns, nil)
|
||
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||
calls++
|
||
return base(ctx, model, msgs, tools, opts)
|
||
}}
|
||
rc := &fakeRunCtx{
|
||
ctx: context.Background(),
|
||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
|
||
execArtifact: &core.Artifact{ID: "art_aaaaaaaaaaaa", Name: "报告.docx", Kind: "document"},
|
||
}
|
||
if err := r.Execute(rc); err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("stream calls = %d, want 1", calls)
|
||
}
|
||
texts := textParts(rc.assistant)
|
||
if len(texts) != 1 || texts[0].Process {
|
||
t.Fatalf("citation should stay the final reply, got %+v", texts)
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerToolCapKeepsParts 验证:超限的那一批工具在执行前被整批拒绝,
|
||
// 已经完成的工具轮部件仍然落库。内核按整批计数,不再执行批内第 N+1 个调用。
|
||
func TestLLMRunnerToolCapKeepsParts(t *testing.T) {
|
||
one := 1
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "先做一件。"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
|
||
{ID: "c1", Name: "make_report", Arguments: "{}"},
|
||
}}},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
|
||
{ID: "c2", Name: "make_report", Arguments: "{}"},
|
||
{ID: "c3", Name: "make_report", Arguments: "{}"},
|
||
}}},
|
||
},
|
||
}
|
||
r := &LLMRunner{Stream: scriptStream(turns, nil)}
|
||
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{
|
||
Model: core.ModelRoute{Chat: "test-model"},
|
||
Budget: core.RunBudget{MaxToolCalls: &one},
|
||
}}
|
||
err := r.Execute(rc)
|
||
if err == nil || !strings.Contains(err.Error(), "工具调用超过上限") {
|
||
t.Fatalf("err = %v", err)
|
||
}
|
||
if rc.toolRuns != 1 {
|
||
t.Fatalf("tool runs = %d, want 1 (the over-cap batch is rejected whole)", rc.toolRuns)
|
||
}
|
||
var calls int
|
||
for _, p := range rc.assistant {
|
||
if p.Type == core.PartToolCall {
|
||
calls++
|
||
}
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("saved tool-call parts = %d, want the completed turn only", calls)
|
||
}
|
||
}
|
||
|
||
// TestLLMRunnerContinuesWhenTruncated 验证:finish_reason=length 且没有工具调用时续写。
|
||
func TestLLMRunnerContinuesWhenTruncated(t *testing.T) {
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "写到一半"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, FinishReason: "length", Message: agentllm.Message{Content: "写到一半"}},
|
||
},
|
||
{
|
||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "后半写完了。"},
|
||
agentllm.Event{Kind: agentllm.KindMessageEnd, FinishReason: "stop", Message: agentllm.Message{Content: "后半写完了。"}},
|
||
},
|
||
}
|
||
var calls int
|
||
var second string
|
||
base := scriptStream(turns, nil)
|
||
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||
calls++
|
||
if calls == 2 {
|
||
second = transcriptOf(msgs)
|
||
}
|
||
return base(ctx, model, msgs, tools, opts)
|
||
}}
|
||
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)
|
||
}
|
||
if calls != 2 {
|
||
t.Fatalf("stream calls = %d, want 2", calls)
|
||
}
|
||
if !strings.Contains(second, "上一段回复因为达到长度上限被截断") {
|
||
t.Fatalf("continuation prompt missing: %s", second)
|
||
}
|
||
texts := textParts(rc.assistant)
|
||
if len(texts) != 2 || texts[0].Text != "写到一半" || texts[1].Text != "后半写完了。" || texts[1].Process {
|
||
t.Fatalf("texts = %+v", texts)
|
||
}
|
||
}
|
||
|
||
func TestToolCallDeltaBeforeExecution(t *testing.T) {
|
||
call := toolCall("echo_tool", `{"script":"print(1)"}`)
|
||
turns := [][]agentllm.Event{
|
||
{
|
||
{Kind: agentllm.KindTextDelta, Text: "我来跑一下。"},
|
||
{Kind: agentllm.KindToolCallDelta, ToolIndex: 0, ToolID: call.ID, ToolName: call.Name, ArgumentsDelta: `{"script":`},
|
||
{Kind: agentllm.KindToolCallDelta, ToolIndex: 0, ArgumentsDelta: `"print(1)"}`},
|
||
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{call}}},
|
||
},
|
||
textDeltaTurn("跑完了。"),
|
||
}
|
||
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
|
||
r := &LLMRunner{Stream: scriptStream(turns, nil)}
|
||
if err := r.Execute(rc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var order []core.StreamEventType
|
||
for _, ev := range rc.emittedEvents() {
|
||
switch ev.typ {
|
||
case core.EvToolCallDelta, core.EvToolCall, core.EvToolResult:
|
||
order = append(order, ev.typ)
|
||
}
|
||
}
|
||
want := []core.StreamEventType{core.EvToolCallDelta, core.EvToolCallDelta, core.EvToolCall, core.EvToolResult}
|
||
if len(order) != len(want) {
|
||
t.Fatalf("order = %v", order)
|
||
}
|
||
for i := range want {
|
||
if order[i] != want[i] {
|
||
t.Fatalf("order = %v", order)
|
||
}
|
||
}
|
||
var delta core.ToolCallDeltaPayload
|
||
for _, ev := range rc.emittedEvents() {
|
||
if ev.typ == core.EvToolCallDelta {
|
||
delta = ev.payload.(core.ToolCallDeltaPayload)
|
||
break
|
||
}
|
||
}
|
||
if delta.Name != call.Name || delta.Index != 0 || delta.Delta == "" {
|
||
t.Fatalf("first delta = %+v", delta)
|
||
}
|
||
var result core.ToolResultPayload
|
||
for _, ev := range rc.emittedEvents() {
|
||
if ev.typ == core.EvToolResult {
|
||
result = ev.payload.(core.ToolResultPayload)
|
||
}
|
||
}
|
||
if result.CallID != call.ID || result.DurationMs < 0 {
|
||
t.Fatalf("result = %+v", result)
|
||
}
|
||
}
|
||
|
||
func transcriptOf(msgs []agentllm.Message) string {
|
||
var b strings.Builder
|
||
for _, m := range msgs {
|
||
b.WriteString(m.Content)
|
||
b.WriteByte('\n')
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (f *fakeRunCtx) Surfaces() []core.SurfaceSnapshot { return nil }
|