Files
Agent-Native/runtime/event_vocabulary_test.go
T
weichen e86e74bc36
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Successful in 52s
Preserve streamed tool delta identity
Why: Backend emitted tool deltas from a separate goroutine, racing with serialized run events and occasionally losing a UI delta.

Evidence: Backend race detector reproduced concurrent event writes; Agent-Native identity test RED→GREEN; full vet and tests pass.

Impact: runtime tool delta events now carry provider slot and call ID for single-owner downstream projection.

Refs: runtime/loop.go; runtime/event_vocabulary_test.go
2026-10-14 02:22:34 +00:00

97 lines
2.9 KiB
Go

package runtime
import (
"context"
"slices"
"sync"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// ProcessEventVocabulary 是过程流消费方(小队过程视图,对应 PI proc 帧转发)
// 依赖的最小事件词汇。U9b Batch1-③ 契约:runtime 必须在真实执行中产出
// 全部词汇;新增词汇向后兼容,删除/改名是破坏性变更(消费方见
// squad process_bridge 的 case 集)。
var ProcessEventVocabulary = []string{
"text_delta",
"reasoning_delta",
"tool_call_delta",
"tool_execution_start",
"tool_execution_end",
"message_end",
}
// vocabScript 构造一段覆盖全部必需词汇的模型流:
// 推理增量 → 文本增量 → 工具调用增量(触发执行)→ 消息结束。
func vocabScript() llm.StreamFunc {
var mu sync.Mutex
step := 0
return func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 8)
go func() {
defer close(ch)
mu.Lock()
defer mu.Unlock()
switch step {
case 0:
step++
ch <- llm.Event{Kind: llm.KindReasoningDelta, ReasoningDelta: "思考"}
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "正文"}
ch <- llm.Event{Kind: llm.KindToolCallDelta, ToolIndex: 2, ToolID: "c1", ToolName: "list_dir", ArgumentsDelta: `{"path":"."}`}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "list_dir", Arguments: `{"path":"."}`}},
}}
default:
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd,
}}
}
}()
return ch
}
}
func TestLoopPreservesToolCallDeltaIdentity(t *testing.T) {
reg := tools.WorkspaceTools(t.TempDir())
var got Event
_, err := Run(context.Background(), Config{
Model: "fake", Tools: reg, Stream: vocabScript(), MaxTurns: 4,
OnEvent: func(ev Event) {
if ev.Type == "tool_call_delta" {
got = ev
}
},
}, llm.Message{Role: llm.RoleUser, Content: "run"})
if err != nil {
t.Fatal(err)
}
if got.ToolIndex != 2 || got.CallID != "c1" || got.Tool != "list_dir" || got.Detail != `{"path":"."}` {
t.Fatalf("tool delta lost its provider identity: %#v", got)
}
}
func TestLoopEmitsProcessVocabulary(t *testing.T) {
reg := tools.WorkspaceTools(t.TempDir())
var observed []string
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Stream: vocabScript(),
OnEvent: func(ev Event) {
observed = append(observed, ev.Type)
},
MaxTurns: 4,
}, llm.Message{Role: llm.RoleUser, Content: "run"})
if err != nil {
t.Fatalf("run: %v", err)
}
for _, want := range ProcessEventVocabulary {
if !slices.Contains(observed, want) {
t.Fatalf("required process event %q not observed; got %v", want, observed)
}
}
}