Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b65f633
Initial AIAgent Calamari
zentron May 24, 2026
93c4ad4
Add OpenAI provider support and multi-provider architecture
zentron May 28, 2026
b72fead
Add Claude Code CLI provider with stream-json parsing and usage servi…
zentron May 29, 2026
ba10f25
Add MCP config support, enums for stream types, and fix deserialization
zentron May 29, 2026
4566c7b
Add Run As user impersonation, tests, and stream processor fixes
zentron May 29, 2026
de17788
Add MCP servers JSON config, stream model updates, and command args b…
zentron Jun 2, 2026
bd86210
Add deployment variables file, effort level, MCP tool permissions, an…
zentron Jun 4, 2026
f08ae17
Fix build.sh
zentron Jun 8, 2026
6ab335f
Undo build fix
zentron Jun 8, 2026
ad61273
Comment
zentron Jun 8, 2026
26fcd67
Try fixing build
zentron Jun 8, 2026
676d834
Refactor: collect explicitly-set env vars into customEnvVars dictionary
zentron Jun 8, 2026
ed4c1b2
Add ShellQuote helper for safe shell command construction
zentron Jun 8, 2026
01f4e52
Rewrite ApplyCredentials with Linux script/su path for user impersona…
zentron Jun 8, 2026
0414f5e
feat: pipe password to stdin for Linux su-based impersonation and upd…
zentron Jun 8, 2026
c344557
rollback build
zentron Jun 9, 2026
96104e7
Change global
zentron Jun 10, 2026
a73d49d
Some tidy and refactor
zentron Jun 12, 2026
737f4bd
Fiddling with perms
zentron Jun 14, 2026
bee2396
Remove old Generic Agent invocation
zentron Jun 15, 2026
88a7176
Update variable naming
zentron Jun 15, 2026
2ea83e0
naming
zentron Jun 15, 2026
43de647
Fix command
zentron Jun 15, 2026
780817a
Merge branch 'main' into robe/poc-aiagent
flin-8 Jun 16, 2026
34c16d3
Rename service message for agent usage
zentron Jun 16, 2026
3ca92f1
Move service message to contracts
zentron Jun 16, 2026
f2a4c5b
Rename step to `run-claude-code`
zentron Jun 16, 2026
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
22 changes: 22 additions & 0 deletions source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Calamari.AiAgent.Tests</AssemblyName>
<RootNamespace>Calamari.AiAgent.Tests</RootNamespace>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;linux-arm;linux-arm64</RuntimeIdentifiers>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="NSubstitute" Version="4.2.2" />
<PackageReference Include="nunit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="TeamCity.VSTest.TestAdapter" Version="1.0.41" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Calamari.AiAgent\Calamari.AiAgent.csproj" />
<ProjectReference Include="..\Calamari.Testing\Calamari.Testing.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Calamari.AiAgent.ClaudeCodeBehaviour;
using FluentAssertions;
using NUnit.Framework;

namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour;

[TestFixture]
public class ClaudeCodeProcessStartInfoFixture
{
[TestCase("simple", "'simple'")]
[TestCase("has space", "'has space'")]
[TestCase("it's", @"'it'\''s'")]
[TestCase("", "''")]
[TestCase("a'b'c", @"'a'\''b'\''c'")]
public void ShellQuote_QuotesCorrectly(string input, string expected)
{
ClaudeCodeProcessStartInfo.ShellQuote(input).Should().Be(expected);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
using System.Linq;
using System.Text;
using Calamari.AiAgent.ClaudeCodeBehaviour;
using Calamari.Common.Plumbing.ServiceMessages;
using Calamari.Testing.Helpers;
using FluentAssertions;
using NUnit.Framework;
using Octopus.Calamari.Contracts.ClaudeCode;

namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour;

[TestFixture]
public class ClaudeCodeStreamProcessorFixture
{
InMemoryLog log = null!;
StringBuilder responseBuilder = null!;
ClaudeCodeStreamProcessor processor = null!;

[SetUp]
public void SetUp()
{
log = new InMemoryLog();
responseBuilder = new StringBuilder();
processor = new ClaudeCodeStreamProcessor(log, responseBuilder);
}

[Test]
public void TextContentBlock_AppendsToResponseAndLogsInfo()
{
var json = """
{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}
""";

processor.ProcessLine(json);

responseBuilder.ToString().Should().Be("Hello world");
log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Info && m.FormattedMessage.Contains("Hello world"));
}

[Test]
public void ThinkingBlock_LogsVerbose()
{
var json = """
{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"Let me reason about this"}]}}
""";

processor.ProcessLine(json);

responseBuilder.ToString().Should().BeEmpty();
log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("Let me reason about this"));
}

[Test]
public void RedactedThinkingBlock_LogsRedactedMessage()
{
var json = """
{"type":"assistant","message":{"content":[{"type":"redacted_thinking"}]}}
""";

processor.ProcessLine(json);

responseBuilder.ToString().Should().BeEmpty();
log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("<redacted>"));
}

[Test]
public void ToolUseBlock_LogsToolName()
{
var json = """
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","id":"toolu_123","input":{"file_path":"/tmp/test.txt"}}]}}
""";

processor.ProcessLine(json);

responseBuilder.ToString().Should().BeEmpty();
log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("Read"));
}

[Test]
public void ToolResultError_LogsWithFailedMessage()
{
var json = """
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_123","is_error":true,"content":"File not found"}]}}
""";

processor.ProcessLine(json);

log.Messages.Should().Contain(m => m.FormattedMessage.Contains("toolu_123") && m.FormattedMessage.Contains("failed"));
}

[Test]
public void ToolResultSuccess_LogsVerbose()
{
var json = """
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_123","name":"Read","is_error":false}]}}
""";

processor.ProcessLine(json);

log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("Read") && m.FormattedMessage.Contains("completed"));
}

[Test]
public void ResultEvent_EmitsUsageServiceMessage()
{
var json = """
{"type":"result","result":"Paris","cost_usd":0.003,"total_cost_usd":0.003,"duration_ms":4521,"duration_api_ms":3200,"num_turns":1,"usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}
""";

processor.ProcessLine(json);

log.ServiceMessages.Should().Contain(m => m.Name == ClaudeCodeServiceMessages.Usage.Name);

var msg = log.ServiceMessages.First(m => m.Name == ClaudeCodeServiceMessages.Usage.Name);
msg.GetValue(ClaudeCodeServiceMessages.Usage.CostUsdAttribute).Should().NotBeNull();
msg.GetValue(ClaudeCodeServiceMessages.Usage.TotalCostUsdAttribute).Should().NotBeNull();
msg.GetValue(ClaudeCodeServiceMessages.Usage.DurationMsAttribute).Should().NotBeNull();
msg.GetValue(ClaudeCodeServiceMessages.Usage.NumTurnsAttribute).Should().NotBeNull();
msg.GetValue(ClaudeCodeServiceMessages.Usage.InputTokensAttribute).Should().NotBeNull();
msg.GetValue(ClaudeCodeServiceMessages.Usage.OutputTokensAttribute).Should().NotBeNull();
msg.GetValue(ClaudeCodeServiceMessages.Usage.CacheReadInputTokensAttribute).Should().NotBeNull();
msg.GetValue(ClaudeCodeServiceMessages.Usage.CacheCreationInputTokensAttribute).Should().NotBeNull();
}

[Test]
public void ResultEvent_FallsBackToResultText_WhenNoAssistantText()
{
var json = """
{"type":"result","result":"Paris","cost_usd":0.001}
""";

processor.ProcessLine(json);

responseBuilder.ToString().Should().Be("Paris");
}

[Test]
public void ResultEvent_DoesNotOverwriteAssistantText()
{
var assistantJson = """
{"type":"assistant","message":{"content":[{"type":"text","text":"The capital is Paris"}]}}
""";
var resultJson = """
{"type":"result","result":"The capital is Paris","cost_usd":0.001}
""";

processor.ProcessLine(assistantJson);
processor.ProcessLine(resultJson);

responseBuilder.ToString().Should().Be("The capital is Paris");
}

[Test]
public void UnknownEventType_LogsVerboseAndDoesNotThrow()
{
var json = """{"type":"stream_event","data":"something"}""";

var act = () => processor.ProcessLine(json);

act.Should().NotThrow();
log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("unhandled event type"));
}

[Test]
public void UnknownContentBlockType_LogsVerboseAndContinues()
{
var json = """
{"type":"assistant","message":{"content":[{"type":"citations","data":"ref1"},{"type":"text","text":"Answer"}]}}
""";

var act = () => processor.ProcessLine(json);

act.Should().NotThrow();
responseBuilder.ToString().Should().Be("Answer");
log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("unhandled block type"));
}

[Test]
public void UnknownSystemSubtype_DoesNotThrow()
{
var json = """
{"type":"system","subtype":"some_new_subtype","data":"whatever"}
""";

var act = () => processor.ProcessLine(json);

act.Should().NotThrow();
}

[Test]
public void ApiRetry_LogsWarning()
{
var json = """
{"type":"system","subtype":"api_retry","attempt":2,"retry_delay_ms":5000,"error":"rate_limit","error_status":429}
""";

processor.ProcessLine(json);

log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Warn && m.FormattedMessage.Contains("rate_limit") && m.FormattedMessage.Contains("5000"));
}

[Test]
public void MalformedJson_DoesNotThrow()
{
var act = () => processor.ProcessLine("this is not json {{{");

act.Should().NotThrow();
}

[Test]
public void MultipleTextBlocks_ConcatenatesResponse()
{
var json1 = """
{"type":"assistant","message":{"content":[{"type":"text","text":"Hello "}]}}
""";
var json2 = """
{"type":"assistant","message":{"content":[{"type":"text","text":"world"}]}}
""";

processor.ProcessLine(json1);
processor.ProcessLine(json2);

responseBuilder.ToString().Should().Be("Hello world");
}

[Test]
public void ServerToolUse_LogsVerbose()
{
var json = """
{"type":"assistant","message":{"content":[{"type":"server_tool_use","name":"web_search"}]}}
""";

processor.ProcessLine(json);

log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("web_search"));
}

[Test]
public void NullMessageContent_DoesNotThrow()
{
var json = """{"type":"assistant","message":{"content":null}}""";

var act = () => processor.ProcessLine(json);

act.Should().NotThrow();
}

[Test]
public void UserTextContent_DoesNotAppendToResponse()
{
var json = """
{"type":"user","message":{"content":[{"type":"text","text":"user input"}]}}
""";

processor.ProcessLine(json);

responseBuilder.ToString().Should().BeEmpty();
log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("user input"));
}

[Test]
public void SyntheticUserMessage_IsSkipped()
{
var json = """
{"type":"user","message":{"content":[{"type":"text","text":"synthetic"}]},"isSynthetic":true}
""";

processor.ProcessLine(json);

log.Messages.Should().NotContain(m => m.FormattedMessage.Contains("synthetic"));
}
}
Loading