using System.Net; using System.Text; using TeamUp.Modules.Integrations.Ai; using TeamUp.SharedKernel.Ai; using Xunit; namespace TeamUp.IntegrationTests; /// /// 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). /// 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 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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastBody = await request.Content!.ReadAsStringAsync(cancellationToken); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responseJson, Encoding.UTF8, "application/json"), }; } } }