Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Temporalio.sln
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.Hosti
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.DiagnosticSource", "src\Temporalio.Extensions.DiagnosticSource\Temporalio.Extensions.DiagnosticSource.csproj", "{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.ToolRegistry", "src\Temporalio.Extensions.ToolRegistry\Temporalio.Extensions.ToolRegistry.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.ToolRegistry.Testing", "src\Temporalio.Extensions.ToolRegistry.Testing\Temporalio.Extensions.ToolRegistry.Testing.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.ToolRegistry.Tests", "tests\Temporalio.Extensions.ToolRegistry.Tests\Temporalio.Extensions.ToolRegistry.Tests.csproj", "{C3D4E5F6-A7B8-9012-CDEF-012345678902}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -46,12 +52,27 @@ Global
{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-012345678902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-012345678902}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-012345678902}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-012345678902}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7AE1422A-0937-40D7-9A62-431DD0E2F6D5} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{D5F245E2-73A2-49C6-8C52-FBE892E87169} = {F2683DAA-F157-448E-96C8-DF7BB019886D}
{D4AC2E2B-1C24-491D-9175-874D448D30FE} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{E8D1975A-5AF7-4375-BAD0-3C256DCB7F87} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{CC7EA7CD-BBE7-448C-8A4B-F8B2D1E55990} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{B2C3D4E5-F6A7-8901-BCDE-F12345678901} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{C3D4E5F6-A7B8-9012-CDEF-012345678902} = {F2683DAA-F157-448E-96C8-DF7BB019886D}
EndGlobalSection
EndGlobal
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace Temporalio.Extensions.ToolRegistry.Testing
{
/// <summary>
/// Implements <see cref="IProvider"/> and returns an error after <see cref="N"/> complete
/// turns. Use it in integration tests to verify that <see cref="AgenticSession"/> resumes from
/// a heartbeat checkpoint after a simulated crash.
/// </summary>
/// <remarks>
/// Example:
/// <code>
/// // First invocation returns an error after 2 turns.
/// // Second invocation (retry) resumes from the last checkpoint.
/// var provider = new CrashAfterTurns { N = 2 };
/// </code>
/// </remarks>
public sealed class CrashAfterTurns : IProvider
{
private int count;

/// <summary>
/// Gets or sets the number of turns to complete before throwing.
/// </summary>
public int N { get; set; }

/// <summary>
/// Gets or sets an optional delegate provider to forward turns to for the first
/// <see cref="N"/> turns. When <c>null</c>, a stub assistant response is returned
/// instead.
/// </summary>
public IProvider? Delegate { get; set; }

/// <summary>
/// Completes a turn normally for the first <see cref="N"/> turns, then throws.
/// </summary>
/// <inheritdoc/>
public Task<ProviderTurnResult> RunTurnAsync(
IList<Dictionary<string, object?>> messages,
IReadOnlyList<ToolDef> tools,
CancellationToken cancellationToken = default)
{
count++;
if (count > N)
{
throw new InvalidOperationException(
$"CrashAfterTurns: simulated crash after {N} turns");
}

if (Delegate != null)
{
return Delegate.RunTurnAsync(messages, tools, cancellationToken);
}

var newMessages = new List<Dictionary<string, object?>>
{
new()
{
["role"] = "assistant",
["content"] = new List<object?>
{
new Dictionary<string, object?> { ["type"] = "text", ["text"] = "..." },
},
},
};
return Task.FromResult(new ProviderTurnResult(newMessages, Done: count >= N));
}
}
}
15 changes: 15 additions & 0 deletions src/Temporalio.Extensions.ToolRegistry.Testing/DispatchCall.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Collections.Generic;

namespace Temporalio.Extensions.ToolRegistry.Testing
{
/// <summary>
/// Records a single tool invocation on <see cref="FakeToolRegistry"/>.
/// </summary>
/// <param name="Name">Tool name that was dispatched.</param>
/// <param name="Input">Input that was passed to the tool.</param>
/// <param name="Result">String value returned by the tool handler.</param>
public sealed record DispatchCall(
string Name,
IReadOnlyDictionary<string, object?> Input,
string Result);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;

namespace Temporalio.Extensions.ToolRegistry.Testing
{
/// <summary>
/// Wraps <see cref="ToolRegistry"/> and records every <see cref="Dispatch"/> call.
/// Implements <see cref="IDispatcher"/> for use with <see cref="MockProvider.WithRegistry(IDispatcher)"/>.
/// </summary>
/// <remarks>
/// Example:
/// <code>
/// var fake = new FakeToolRegistry();
/// fake.Register(def, input => "result");
/// // Use fake.WithRegistry(fake) on MockProvider to record calls.
/// Assert.Single(fake.Calls);
/// Assert.Equal("tool-name", fake.Calls[0].Name);
/// </code>
/// </remarks>
public sealed class FakeToolRegistry : IDispatcher
{
private readonly ToolRegistry inner = new();
private readonly List<DispatchCall> calls = new();

/// <summary>
/// Gets all tool dispatch invocations in order.
/// </summary>
public IList<DispatchCall> Calls => calls;

/// <summary>
/// Registers a tool definition and its handler.
/// </summary>
/// <param name="def">Tool definition.</param>
/// <param name="handler">Function called when the tool is dispatched.</param>
public void Register(ToolDef def, Func<IReadOnlyDictionary<string, object?>, string> handler) =>
inner.Register(def, handler);

/// <summary>
/// Records the call and delegates dispatch to the underlying registry.
/// </summary>
/// <param name="name">Tool name.</param>
/// <param name="input">Tool input.</param>
/// <returns>String result from the handler.</returns>
public string Dispatch(string name, IReadOnlyDictionary<string, object?> input)
{
var result = inner.Dispatch(name, input);
calls.Add(new(name, input, result));
return result;
}

/// <summary>
/// Returns the underlying registry's definitions.
/// </summary>
/// <returns>Read-only list of registered tool definitions.</returns>
public IReadOnlyList<ToolDef> Definitions() => inner.Definitions();
}
}
20 changes: 20 additions & 0 deletions src/Temporalio.Extensions.ToolRegistry.Testing/IDispatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Collections.Generic;

namespace Temporalio.Extensions.ToolRegistry.Testing
{
/// <summary>
/// Implemented by <see cref="ToolRegistry"/> and <see cref="FakeToolRegistry"/>.
/// Pass a <see cref="FakeToolRegistry"/> to <see cref="MockProvider.WithRegistry(IDispatcher)"/> to record
/// which tool calls the scripted responses trigger.
/// </summary>
public interface IDispatcher
{
/// <summary>
/// Dispatches a tool call by name and returns the string result.
/// </summary>
/// <param name="name">Tool name.</param>
/// <param name="input">Tool input.</param>
/// <returns>String result.</returns>
string Dispatch(string name, IReadOnlyDictionary<string, object?> input);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace Temporalio.Extensions.ToolRegistry.Testing
{
/// <summary>
/// A pre-canned session that returns fixed issues without any LLM calls.
/// Use it to test code that calls <see cref="AgenticSession.RunWithSessionAsync"/> and
/// inspects session state without an API key or a Temporal server.
/// </summary>
/// <remarks>
/// Example:
/// <code>
/// var session = new MockAgenticSession
/// {
/// Issues = { new Dictionary&lt;string, object?&gt; { ["type"] = "missing", ["symbol"] = "x" } },
/// };
/// await session.RunToolLoopAsync(null!, null!, "sys", "prompt");
/// // session.Issues still contains the pre-canned entry
/// </code>
/// </remarks>
public sealed class MockAgenticSession
{
private readonly List<Dictionary<string, object?>> messages = new();
private readonly List<Dictionary<string, object?>> issues = new();

/// <summary>
/// Gets the pre-canned or accumulated conversation messages.
/// </summary>
public IList<Dictionary<string, object?>> Messages => messages;

/// <summary>
/// Gets the pre-canned or accumulated issues.
/// </summary>
public IList<Dictionary<string, object?>> Issues => issues;

/// <summary>
/// Gets the prompt value that was passed to the last call of
/// <see cref="RunToolLoopAsync"/>. Useful for asserting that callers pass the correct
/// initial prompt.
/// </summary>
public string? CapturedPrompt { get; private set; }

/// <summary>
/// No-op: does not call any LLM or record a heartbeat. Adds the prompt as the first user
/// message if <see cref="Messages"/> is empty.
/// </summary>
/// <param name="provider">Not used; accepted for interface compatibility.</param>
/// <param name="registry">Not used; may be null.</param>
/// <param name="system">Not used; present for API symmetry.</param>
/// <param name="prompt">Initial user prompt (added if messages are empty).</param>
/// <param name="cancellationToken">Not used.</param>
/// <returns>A completed task.</returns>
public Task RunToolLoopAsync(
IProvider? provider,
ToolRegistry? registry,
string system,
string prompt,
CancellationToken cancellationToken = default)
{
CapturedPrompt = prompt;
if (messages.Count == 0)
{
messages.Add(new() { ["role"] = "user", ["content"] = prompt });
}
return Task.CompletedTask;
}
}
}
Loading