Files
Agent-Native/runtime/loop_event_fidelity_test.go
T
weichen cff69355f7 Carry tool-call identity and real outcome in process events
Why: F14 source half - exec.ProcessEvent lacked CallID and outcome
fields, forcing consumers to pair events by tool name and mark every
end ok; cancellations and failures displayed as success downstream.
Evidence: loop/adapter tests pass (verify-u11a.json); pairing, denial,
cancellation and concurrent-callback cases added.
Impact: new omitempty fields only; legacy events unchanged.
Refs: feature/agent-stack-audit-remediation-b2 U11a
2026-10-22 01:52:12 +00:00

287 lines
8.5 KiB
Go

package runtime
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
type failTool struct{ reason string }
func (f failTool) Name() string { return "echo" }
func (f failTool) Description() string { return "echo that fails" }
func (f failTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (f failTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
return tools.Result{Content: f.reason, IsError: true}, nil
}
// TestLoopSameNameToolsCarryDistinctCallIDs 同名工具连续/并行调用时,
// start/end 的 CallID 必须互异且可按 ID 配对(不得按 Tool 名合并)。
func TestLoopSameNameToolsCarryDistinctCallIDs(t *testing.T) {
for _, mode := range []string{ExecSequential, ExecParallel} {
t.Run(mode, func(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
var starts, ends []Event
var mu sync.Mutex
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
ToolExecution: mode,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "call-a", Name: "echo", Arguments: `{}`},
{ID: "call-b", Name: "echo", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
OnEvent: func(ev Event) {
mu.Lock()
defer mu.Unlock()
switch ev.Type {
case "tool_execution_start":
starts = append(starts, ev)
case "tool_execution_end":
ends = append(ends, ev)
}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 2 || len(starts) != 2 || len(ends) != 2 {
t.Fatalf("hits=%d starts=%d ends=%d", hits, len(starts), len(ends))
}
byStart := map[string]Event{}
for _, ev := range starts {
if ev.CallID == "" || ev.Tool != "echo" {
t.Fatalf("start missing identity: %+v", ev)
}
if _, dup := byStart[ev.CallID]; dup {
t.Fatalf("duplicate start CallID %q", ev.CallID)
}
byStart[ev.CallID] = ev
}
for _, ev := range ends {
if ev.Status != "ok" {
t.Fatalf("end status = %q, want ok: %+v", ev.Status, ev)
}
if _, ok := byStart[ev.CallID]; !ok {
t.Fatalf("end CallID %q has no matching start", ev.CallID)
}
delete(byStart, ev.CallID)
}
if len(byStart) != 0 {
t.Fatalf("unpaired starts: %+v", byStart)
}
if _, ok := map[string]Event{ends[0].CallID: {}, ends[1].CallID: {}}["call-a"]; !ok {
t.Fatalf("missing call-a in ends %+v", ends)
}
if ends[0].CallID == ends[1].CallID {
t.Fatalf("CallIDs must differ, both %q", ends[0].CallID)
}
})
}
}
func TestLoopFailingToolEndStatusError(t *testing.T) {
reg := tools.NewRegistry(t.TempDir())
reg.Register(failTool{reason: "boom-detail"})
var end Event
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c-fail", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
OnEvent: func(ev Event) {
if ev.Type == "tool_execution_end" {
end = ev
}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if end.CallID != "c-fail" || end.Status != "error" || end.ErrorReason != "boom-detail" {
t.Fatalf("end = %+v", end)
}
}
func TestLoopDeniedToolEndStatusDenied(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PreToolUse {
return hooks.Outcome{Decision: hooks.Deny, Reason: "策略拒绝"}
}
return hooks.Outcome{}
})
var end Event
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c-deny", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
OnEvent: func(ev Event) {
if ev.Type == "tool_execution_end" {
end = ev
}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool ran %d times", hits)
}
if end.CallID != "c-deny" || end.Status != "denied" || end.ErrorReason != "策略拒绝" {
t.Fatalf("end = %+v", end)
}
if end.Status == "ok" {
t.Fatal("denied must not be reported ok")
}
}
func TestLoopCanceledToolEndStatusCanceled(t *testing.T) {
bt := &blockingTool{canceled: make(chan struct{})}
reg := tools.NewRegistry(t.TempDir())
reg.Register(bt)
ctx, cancel := context.WithCancel(context.Background())
firstCall := true
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
if firstCall {
firstCall = false
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c-cancel", Name: "block_forever", Arguments: `{}`}},
}}
return
}
select {
case <-ctx.Done():
ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}}
case <-time.After(3 * time.Second):
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
}
}()
return ch
}
var ends []Event
var mu sync.Mutex
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
_, err := Run(ctx, Config{
Model: "fake",
Tools: reg,
Stream: stream,
OnEvent: func(ev Event) {
if ev.Type == "tool_execution_end" {
mu.Lock()
ends = append(ends, ev)
mu.Unlock()
}
},
}, llm.Message{Role: llm.RoleUser, Content: "block"})
if err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(ends) == 0 {
t.Fatal("expected tool_execution_end")
}
end := ends[0]
if end.CallID != "c-cancel" {
t.Fatalf("CallID = %q", end.CallID)
}
if end.Status != "canceled" {
t.Fatalf("Status = %q, want canceled (not ok); end=%+v", end.Status, end)
}
if end.ErrorReason == "" {
t.Fatalf("ErrorReason empty on canceled: %+v", end)
}
}
type cancelBatchTool struct {
cancel context.CancelFunc
}
func (c cancelBatchTool) Name() string { return "cancel_batch" }
func (c cancelBatchTool) Description() string { return "cancels ctx then returns" }
func (c cancelBatchTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (c cancelBatchTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
c.cancel()
return tools.Result{Content: "cancelled-siblings"}, nil
}
// TestLoopSkippedCancelEmitsCanceledNotOK:串行批内前一个调用取消 ctx 后,
// 后续未执行的调用必须发 Status=canceled,不得报 ok。
func TestLoopSkippedCancelEmitsCanceledNotOK(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
reg := tools.NewRegistry(t.TempDir())
reg.Register(cancelBatchTool{cancel: cancel})
hits := 0
reg.Register(hitTool{hits: &hits})
var ends []Event
_, err := Run(ctx, Config{
Model: "fake",
Tools: reg,
ToolExecution: ExecSequential,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "c-first", Name: "cancel_batch", Arguments: `{}`},
{ID: "c-skip", Name: "echo", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
OnEvent: func(ev Event) {
if ev.Type == "tool_execution_end" {
ends = append(ends, ev)
}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
_ = err
if hits != 0 {
t.Fatalf("echo should not run after cancel, hits=%d", hits)
}
var skip *Event
for i := range ends {
if ends[i].CallID == "c-skip" {
skip = &ends[i]
}
}
if skip == nil {
t.Fatalf("missing end for skipped call: %+v", ends)
}
if skip.Status != "canceled" {
t.Fatalf("skipped end Status=%q want canceled: %+v", skip.Status, skip)
}
if skip.ErrorReason == "" || strings.Contains(skip.Status, "ok") {
t.Fatalf("skipped end malformed: %+v", skip)
}
}