Files
Teamup/tests/TeamUp.IntegrationTests/ModelClientToolTests.cs
T
soroush.asadi c8d9af6191 MCP tool-use execution loop for autonomous agent runs
Autonomous agents with MCP tools now run a bounded tool-use loop: the model may
call tools (executed via the gateway, results fed back) until it returns a final
answer. Gated/DraftOnly agents get the tool catalog as data but never auto-call —
a human-in-the-loop agent never autonomously reaches an external tool.

Extends IModelClient with tool definitions and a tool-use conversation, adds the
OpenAI-compatible tool serialization/parsing plus a deterministic "tooluse" stub
client, and records every tool call in the run trace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:20:48 +03:30

95 lines
3.9 KiB
C#

using System.Net;
using System.Text;
using TeamUp.Modules.Integrations.Ai;
using TeamUp.SharedKernel.Ai;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// The model-client tool-use plumbing: the OpenAI-compatible adapter serializes tools + a tool-use
/// conversation and parses tool calls out of the reply; the deterministic "tooluse" stub drives the
/// loop (ask for a tool, then answer once a result is present).
/// </summary>
public sealed class ModelClientToolTests
{
[Fact]
public async Task OpenAi_adapter_sends_tools_and_parses_tool_calls()
{
const string reply =
"""{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"search_issues","arguments":"{\"q\":\"bug\"}"}}]}}]}""";
var handler = new CapturingHandler(reply);
var client = new OpenAiCompatibleModelClient(new HttpClient(handler));
var request = new ModelRequest(
"openai", "gpt-4o", "sk-test", null, "find bugs", MaxTokens: 512,
Tools: [new ModelTool("search_issues", "Search the tracker.", """{"type":"object"}""")],
Messages: [new ModelMessage("user", "find bugs")]);
var completion = await client.CompleteAsync(request);
Assert.True(completion.Success);
var call = Assert.Single(completion.ToolCalls!);
Assert.Equal("call_abc", call.Id);
Assert.Equal("search_issues", call.Name);
Assert.Contains("bug", call.ArgumentsJson);
// The outgoing body carries the tool definition and the conversation.
Assert.Contains("\"tools\"", handler.LastBody);
Assert.Contains("search_issues", handler.LastBody);
Assert.Contains("find bugs", handler.LastBody);
}
[Fact]
public async Task OpenAi_adapter_returns_plain_text_when_no_tool_calls()
{
const string reply = """{"choices":[{"message":{"role":"assistant","content":"Here are the bugs."}}]}""";
var client = new OpenAiCompatibleModelClient(new HttpClient(new CapturingHandler(reply)));
var completion = await client.CompleteAsync(new ModelRequest("openai", "gpt-4o", "sk", null, "hi"));
Assert.True(completion.Success);
Assert.Equal("Here are the bugs.", completion.Text);
Assert.Null(completion.ToolCalls);
}
[Fact]
public async Task ToolUse_stub_asks_for_a_tool_then_answers_once_a_result_is_present()
{
var stub = new ToolUseStubModelClient();
List<ModelTool> tools = [new ModelTool("lookup", null, "{}")];
var first = await stub.CompleteAsync(new ModelRequest(
"tooluse", "m", "", null, "do it", Tools: tools, Messages: [new ModelMessage("user", "do it")]));
var toolCall = Assert.Single(first.ToolCalls!);
Assert.Equal("lookup", toolCall.Name);
Assert.Null(first.Text);
var second = await stub.CompleteAsync(new ModelRequest(
"tooluse", "m", "", null, "do it", Tools: tools,
Messages:
[
new ModelMessage("user", "do it"),
new ModelMessage("assistant", null, first.ToolCalls),
new ModelMessage("tool", "the result", ToolCallId: toolCall.Id),
]));
Assert.Null(second.ToolCalls);
Assert.Contains("do it", second.Text!);
}
private sealed class CapturingHandler(string responseJson) : HttpMessageHandler
{
public string LastBody { get; private set; } = string.Empty;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastBody = await request.Content!.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseJson, Encoding.UTF8, "application/json"),
};
}
}
}