Skip to content

Commit 97ce00b

Browse files
feat(codebuddy-console): add Codebuddy dedicated console implementation
Add dedicated Codebuddy console built on shared ConsoleTesting harness with provider definition, execution options, console runner, scenarios, and test project reference. Co-Authored-By: Hagicode <noreply@hagicode.com>
1 parent a43fd7e commit 97ce00b

13 files changed

Lines changed: 891 additions & 0 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using HagiCode.Libs.ConsoleTesting;
2+
3+
namespace HagiCode.Libs.Codebuddy.Console;
4+
5+
public static class CodebuddyConsoleDefinition
6+
{
7+
public static ProviderConsoleDefinition Instance { get; } = new(
8+
consoleName: "HagiCode.Libs.Codebuddy.Console",
9+
providerDisplayName: "CodeBuddy",
10+
defaultProviderName: "codebuddy",
11+
helpDescription: "Dedicated provider validation for the CodeBuddy CLI.",
12+
aliases: ["codebuddy-cli"],
13+
optionLines:
14+
[
15+
"--repo <path> Include the repository summary scenario in the suite",
16+
"--model <model> Override the CodeBuddy model for scenario runs"
17+
],
18+
exampleLines:
19+
[
20+
"HagiCode.Libs.Codebuddy.Console",
21+
"HagiCode.Libs.Codebuddy.Console --test-provider codebuddy-cli",
22+
"HagiCode.Libs.Codebuddy.Console --test-provider-full --repo ."
23+
]);
24+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using HagiCode.Libs.Providers.Codebuddy;
2+
3+
namespace HagiCode.Libs.Codebuddy.Console;
4+
5+
public sealed record CodebuddyConsoleExecutionOptions(
6+
string? Model,
7+
string? RepositoryPath)
8+
{
9+
public const string DefaultModel = "glm-4.7";
10+
11+
public static CodebuddyConsoleExecutionOptions Parse(IReadOnlyList<string> args)
12+
{
13+
ArgumentNullException.ThrowIfNull(args);
14+
15+
string? model = null;
16+
string? repositoryPath = null;
17+
18+
for (var index = 0; index < args.Count; index++)
19+
{
20+
var argument = args[index];
21+
switch (argument)
22+
{
23+
case "--model":
24+
model = ReadValue(args, ref index, argument);
25+
break;
26+
case "--repo":
27+
repositoryPath = ReadValue(args, ref index, argument);
28+
break;
29+
default:
30+
throw new ArgumentException($"Unknown option: {argument}");
31+
}
32+
}
33+
34+
return new CodebuddyConsoleExecutionOptions(model, repositoryPath);
35+
}
36+
37+
public CodebuddyOptions CreateBaseOptions()
38+
{
39+
return new CodebuddyOptions
40+
{
41+
Model = string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model,
42+
};
43+
}
44+
45+
private static string ReadValue(IReadOnlyList<string> args, ref int index, string flag)
46+
{
47+
if (index + 1 >= args.Count || args[index + 1].StartsWith("-", StringComparison.Ordinal))
48+
{
49+
throw new ArgumentException($"{flag} requires a value.");
50+
}
51+
52+
index++;
53+
return args[index];
54+
}
55+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using HagiCode.Libs.Codebuddy.Console.Scenarios;
2+
using HagiCode.Libs.ConsoleTesting;
3+
using HagiCode.Libs.Providers;
4+
using HagiCode.Libs.Providers.Codebuddy;
5+
6+
namespace HagiCode.Libs.Codebuddy.Console;
7+
8+
public sealed class CodebuddyConsoleRunner : ProviderConsoleRunnerBase<ICliProvider<CodebuddyOptions>>
9+
{
10+
public CodebuddyConsoleRunner(
11+
ProviderConsoleDefinition definition,
12+
ICliProvider<CodebuddyOptions> provider,
13+
ProviderConsoleOutputFormatter formatter)
14+
: base(definition, provider, formatter)
15+
{
16+
}
17+
18+
protected override void ValidateAdditionalArgs(IReadOnlyList<string> additionalArgs)
19+
{
20+
_ = CodebuddyConsoleExecutionOptions.Parse(additionalArgs);
21+
}
22+
23+
protected override IReadOnlyList<ProviderConsoleScenario<ICliProvider<CodebuddyOptions>>> CreateScenarios(
24+
IReadOnlyList<string> additionalArgs)
25+
{
26+
var options = CodebuddyConsoleExecutionOptions.Parse(additionalArgs);
27+
var scenarios = new List<ProviderConsoleScenario<ICliProvider<CodebuddyOptions>>>
28+
{
29+
SimplePromptScenario.Create(options),
30+
ComplexPromptScenario.Create(options),
31+
SessionResumeScenario.Create(options)
32+
};
33+
34+
if (!string.IsNullOrWhiteSpace(options.RepositoryPath))
35+
{
36+
scenarios.Add(RepositorySummaryScenario.Create(options));
37+
}
38+
39+
return scenarios;
40+
}
41+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using HagiCode.Libs.Providers;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace HagiCode.Libs.Codebuddy.Console;
5+
6+
public static class ConsoleHost
7+
{
8+
public static ServiceProvider BuildServiceProvider()
9+
{
10+
var services = new ServiceCollection();
11+
services.AddHagiCodeLibs();
12+
return services.BuildServiceProvider();
13+
}
14+
15+
public static ICliProvider<TOptions> GetProvider<TOptions>(ServiceProvider provider)
16+
where TOptions : class
17+
{
18+
return provider.GetRequiredService<ICliProvider<TOptions>>();
19+
}
20+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<NoWarn>$(NoWarn);1591</NoWarn>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<ProjectReference Include="..\HagiCode.Libs.ConsoleTesting\HagiCode.Libs.ConsoleTesting.csproj" />
10+
<ProjectReference Include="..\HagiCode.Libs.Providers\HagiCode.Libs.Providers.csproj" />
11+
</ItemGroup>
12+
13+
<ItemGroup>
14+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
15+
</ItemGroup>
16+
</Project>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using HagiCode.Libs.ConsoleTesting;
2+
using HagiCode.Libs.Providers.Codebuddy;
3+
4+
namespace HagiCode.Libs.Codebuddy.Console;
5+
6+
public static class Program
7+
{
8+
public static async Task<int> Main(string[] args)
9+
{
10+
var definition = CodebuddyConsoleDefinition.Instance;
11+
12+
await using var services = ConsoleHost.BuildServiceProvider();
13+
var provider = ConsoleHost.GetProvider<CodebuddyOptions>(services);
14+
var formatter = new ProviderConsoleOutputFormatter();
15+
var runner = new CodebuddyConsoleRunner(definition, provider, formatter);
16+
17+
return await ProviderConsoleCommandDispatcher.DispatchAsync(args, definition, runner);
18+
}
19+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System.Text.Json;
2+
using System.Text;
3+
using HagiCode.Libs.Providers;
4+
using HagiCode.Libs.Providers.Codebuddy;
5+
6+
namespace HagiCode.Libs.Codebuddy.Console.Scenarios;
7+
8+
internal static class CodebuddyScenarioMessageReader
9+
{
10+
public static async Task<CodebuddyScenarioExecutionResult> ReadExecutionResultAsync(
11+
ICliProvider<CodebuddyOptions> provider,
12+
CodebuddyOptions options,
13+
string prompt,
14+
CancellationToken cancellationToken = default)
15+
{
16+
var messages = new List<string>();
17+
var assistantTextBuilder = new StringBuilder();
18+
string? sessionId = null;
19+
20+
await foreach (var message in provider.ExecuteAsync(options, prompt, cancellationToken))
21+
{
22+
if (TryGetFailureMessage(message.Content, out var failureMessage))
23+
{
24+
throw new InvalidOperationException(failureMessage);
25+
}
26+
27+
if (TryGetSessionId(message.Content, out var resolvedSessionId))
28+
{
29+
sessionId ??= resolvedSessionId;
30+
}
31+
32+
if (string.Equals(message.Type, "assistant", StringComparison.OrdinalIgnoreCase) &&
33+
TryGetText(message.Content, out var assistantText) &&
34+
!string.IsNullOrWhiteSpace(assistantText))
35+
{
36+
messages.Add(assistantText);
37+
assistantTextBuilder.Append(assistantText);
38+
}
39+
40+
if (string.Equals(message.Type, "terminal.completed", StringComparison.OrdinalIgnoreCase))
41+
{
42+
break;
43+
}
44+
}
45+
46+
return new CodebuddyScenarioExecutionResult(messages, assistantTextBuilder.ToString().Trim(), sessionId);
47+
}
48+
49+
public static async Task<IReadOnlyList<string>> ReadAssistantMessagesAsync(
50+
ICliProvider<CodebuddyOptions> provider,
51+
CodebuddyOptions options,
52+
string prompt,
53+
CancellationToken cancellationToken = default)
54+
{
55+
var result = await ReadExecutionResultAsync(provider, options, prompt, cancellationToken);
56+
return result.Messages;
57+
}
58+
59+
private static bool TryGetSessionId(JsonElement content, out string? sessionId)
60+
{
61+
sessionId = null;
62+
if (content.ValueKind != JsonValueKind.Object)
63+
{
64+
return false;
65+
}
66+
67+
if (!content.TryGetProperty("session_id", out var sessionIdElement) ||
68+
sessionIdElement.ValueKind != JsonValueKind.String)
69+
{
70+
return false;
71+
}
72+
73+
sessionId = sessionIdElement.GetString();
74+
return !string.IsNullOrWhiteSpace(sessionId);
75+
}
76+
77+
private static bool TryGetText(JsonElement content, out string? text)
78+
{
79+
text = null;
80+
if (content.ValueKind != JsonValueKind.Object ||
81+
!content.TryGetProperty("text", out var textElement) ||
82+
textElement.ValueKind != JsonValueKind.String)
83+
{
84+
return false;
85+
}
86+
87+
text = textElement.GetString();
88+
return !string.IsNullOrWhiteSpace(text);
89+
}
90+
91+
private static bool TryGetFailureMessage(JsonElement content, out string? message)
92+
{
93+
message = null;
94+
if (content.ValueKind != JsonValueKind.Object)
95+
{
96+
return false;
97+
}
98+
99+
if (content.TryGetProperty("type", out var typeElement) &&
100+
typeElement.ValueKind == JsonValueKind.String &&
101+
string.Equals(typeElement.GetString(), "terminal.failed", StringComparison.OrdinalIgnoreCase))
102+
{
103+
if (content.TryGetProperty("message", out var messageElement) &&
104+
messageElement.ValueKind == JsonValueKind.String)
105+
{
106+
message = messageElement.GetString();
107+
return !string.IsNullOrWhiteSpace(message);
108+
}
109+
}
110+
111+
return false;
112+
}
113+
}
114+
115+
internal sealed record CodebuddyScenarioExecutionResult(
116+
IReadOnlyList<string> Messages,
117+
string AssistantText,
118+
string? SessionId);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using HagiCode.Libs.ConsoleTesting;
2+
using HagiCode.Libs.Providers;
3+
using HagiCode.Libs.Providers.Codebuddy;
4+
5+
namespace HagiCode.Libs.Codebuddy.Console.Scenarios;
6+
7+
public static class ComplexPromptScenario
8+
{
9+
private const int MinResponseLength = 40;
10+
11+
public static ProviderConsoleScenario<ICliProvider<CodebuddyOptions>> Create(CodebuddyConsoleExecutionOptions executionOptions)
12+
{
13+
ArgumentNullException.ThrowIfNull(executionOptions);
14+
15+
return new ProviderConsoleScenario<ICliProvider<CodebuddyOptions>>(
16+
"Complex Prompt",
17+
"Validate a bounded multi-step analysis prompt.",
18+
(provider, cancellationToken) => ExecuteAsync(provider, executionOptions, cancellationToken));
19+
}
20+
21+
private static async Task<ProviderConsoleScenarioResult> ExecuteAsync(
22+
ICliProvider<CodebuddyOptions> provider,
23+
CodebuddyConsoleExecutionOptions executionOptions,
24+
CancellationToken cancellationToken)
25+
{
26+
var prompt = "Give two short bullet points about software testing: " +
27+
"one advantage and one trade-off. Mention both labels explicitly.";
28+
29+
var result = await CodebuddyScenarioMessageReader.ReadExecutionResultAsync(
30+
provider,
31+
executionOptions.CreateBaseOptions(),
32+
prompt,
33+
cancellationToken);
34+
35+
if (result.Messages.Count == 0)
36+
{
37+
return new ProviderConsoleScenarioResult(provider.Name, "Complex Prompt", false, 0, ErrorMessage: "No assistant messages received from provider.");
38+
}
39+
40+
var combined = result.AssistantText;
41+
if (combined.Length < MinResponseLength)
42+
{
43+
return new ProviderConsoleScenarioResult(
44+
provider.Name,
45+
"Complex Prompt",
46+
false,
47+
0,
48+
ErrorMessage: $"Response too short: {combined.Length} chars (minimum {MinResponseLength}). Response: {combined}");
49+
}
50+
51+
var normalized = combined.ToLowerInvariant();
52+
var mentionsAdvantage = normalized.Contains("advantage", StringComparison.Ordinal) ||
53+
normalized.Contains("pro", StringComparison.Ordinal);
54+
var mentionsTradeOff = normalized.Contains("trade-off", StringComparison.Ordinal) ||
55+
normalized.Contains("tradeoff", StringComparison.Ordinal) ||
56+
normalized.Contains("con", StringComparison.Ordinal) ||
57+
normalized.Contains("disadvantage", StringComparison.Ordinal);
58+
if (!mentionsAdvantage || !mentionsTradeOff)
59+
{
60+
return new ProviderConsoleScenarioResult(
61+
provider.Name,
62+
"Complex Prompt",
63+
false,
64+
0,
65+
ErrorMessage: $"Response must mention both an advantage and a trade-off. Response: {combined}");
66+
}
67+
68+
return new ProviderConsoleScenarioResult(provider.Name, "Complex Prompt", true, 0);
69+
}
70+
}

0 commit comments

Comments
 (0)