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) } }