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
287 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|