Skip to content

Commit a43fd7e

Browse files
feat(providers): add Codebuddy provider implementation
Add CodebuddyProvider with options model and ACP message mapper for Codebuddy CLI integration. Register provider in DI container with ICliProvider and ICliProvider<CodebuddyOptions> registrations. Co-Authored-By: Hagicode <noreply@hagicode.com>
1 parent 1f94ea2 commit a43fd7e

6 files changed

Lines changed: 1081 additions & 2 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
using System.Text.Json;
2+
using HagiCode.Libs.Core.Acp;
3+
using HagiCode.Libs.Core.Transport;
4+
5+
namespace HagiCode.Libs.Providers.Codebuddy;
6+
7+
internal static class CodebuddyAcpMessageMapper
8+
{
9+
public static CliMessage CreateSessionLifecycleMessage(AcpSessionHandle sessionHandle)
10+
{
11+
return new CliMessage(
12+
sessionHandle.IsResumed ? "session.resumed" : "session.started",
13+
JsonSerializer.SerializeToElement(new Dictionary<string, object?>
14+
{
15+
["type"] = sessionHandle.IsResumed ? "session.resumed" : "session.started",
16+
["session_id"] = sessionHandle.SessionId
17+
}));
18+
}
19+
20+
public static CliMessage CreateTerminalMessage(string sessionId, JsonElement promptResult)
21+
{
22+
var stopReason = TryGetPromptResultStopReason(promptResult);
23+
var messageType = IsFailureStopReason(stopReason) ? "terminal.failed" : "terminal.completed";
24+
25+
return new CliMessage(
26+
messageType,
27+
JsonSerializer.SerializeToElement(new Dictionary<string, object?>
28+
{
29+
["type"] = messageType,
30+
["session_id"] = sessionId,
31+
["stop_reason"] = stopReason,
32+
["text"] = TryExtractPromptResultText(promptResult, out var text) ? text : null,
33+
["result"] = promptResult
34+
}));
35+
}
36+
37+
public static CliMessage CreateAssistantMessage(string sessionId, string? text, JsonElement? rawPayload = null)
38+
{
39+
return new CliMessage(
40+
"assistant",
41+
JsonSerializer.SerializeToElement(new Dictionary<string, object?>
42+
{
43+
["type"] = "assistant",
44+
["session_id"] = sessionId,
45+
["text"] = text,
46+
["update"] = rawPayload
47+
}));
48+
}
49+
50+
public static CliMessage CreateTerminalFailureMessage(string sessionId, Exception exception)
51+
{
52+
return new CliMessage(
53+
"terminal.failed",
54+
JsonSerializer.SerializeToElement(new Dictionary<string, object?>
55+
{
56+
["type"] = "terminal.failed",
57+
["session_id"] = sessionId,
58+
["message"] = exception.Message
59+
}));
60+
}
61+
62+
public static IReadOnlyList<CliMessage> NormalizeNotification(AcpNotification notification)
63+
{
64+
if (!string.Equals(notification.Method, "session/update", StringComparison.OrdinalIgnoreCase) ||
65+
notification.Parameters.ValueKind != JsonValueKind.Object)
66+
{
67+
return
68+
[
69+
new CliMessage(
70+
"session.notification",
71+
JsonSerializer.SerializeToElement(new Dictionary<string, object?>
72+
{
73+
["type"] = "session.notification",
74+
["method"] = notification.Method,
75+
["params"] = notification.Parameters
76+
}))
77+
];
78+
}
79+
80+
var parameters = notification.Parameters;
81+
var sessionId = TryGetString(parameters, "sessionId") ?? string.Empty;
82+
if (!parameters.TryGetProperty("update", out var updateElement) || updateElement.ValueKind != JsonValueKind.Object)
83+
{
84+
return
85+
[
86+
new CliMessage(
87+
"session.update",
88+
JsonSerializer.SerializeToElement(new Dictionary<string, object?>
89+
{
90+
["type"] = "session.update",
91+
["session_id"] = sessionId,
92+
["update"] = parameters
93+
}))
94+
];
95+
}
96+
97+
var updateKind = TryGetString(updateElement, "sessionUpdate") ?? "unknown";
98+
return updateKind switch
99+
{
100+
"agent_message_chunk" => [CreateAssistantUpdateMessage(sessionId, updateElement, "assistant")],
101+
"agent_thought_chunk" => [CreateAssistantUpdateMessage(sessionId, updateElement, "assistant.thought")],
102+
"tool_call" => [CreateUpdateMessage("tool.call", sessionId, updateElement)],
103+
"tool_call_update" => [CreateUpdateMessage("tool.update", sessionId, updateElement)],
104+
"prompt_completed" => [CreatePromptCompletedMessage(sessionId, updateElement)],
105+
_ =>
106+
[
107+
CreateUpdateMessage("session.update", sessionId, updateElement)
108+
]
109+
};
110+
}
111+
112+
public static bool ShouldPreferPromptCompletedNotification(JsonElement promptResult)
113+
{
114+
var stopReason = TryGetPromptResultStopReason(promptResult);
115+
return string.Equals(stopReason, "end_turn", StringComparison.OrdinalIgnoreCase) ||
116+
string.Equals(stopReason, "completed", StringComparison.OrdinalIgnoreCase) ||
117+
string.Equals(stopReason, "success", StringComparison.OrdinalIgnoreCase);
118+
}
119+
120+
public static bool IsFailurePromptResult(JsonElement promptResult)
121+
{
122+
return IsFailureStopReason(TryGetPromptResultStopReason(promptResult));
123+
}
124+
125+
public static bool TryExtractPromptResultText(JsonElement promptResult, out string? text)
126+
{
127+
text = TryGetString(promptResult, "outputText") ?? TryGetString(promptResult, "text");
128+
return !string.IsNullOrWhiteSpace(text);
129+
}
130+
131+
public static bool TryExtractMessageText(JsonElement content, out string? text)
132+
{
133+
text = null;
134+
if (content.ValueKind != JsonValueKind.Object ||
135+
!content.TryGetProperty("text", out var textElement) ||
136+
textElement.ValueKind != JsonValueKind.String)
137+
{
138+
return false;
139+
}
140+
141+
text = textElement.GetString();
142+
return !string.IsNullOrWhiteSpace(text);
143+
}
144+
145+
private static CliMessage CreateAssistantUpdateMessage(string sessionId, JsonElement updateElement, string messageType)
146+
{
147+
return new CliMessage(
148+
messageType,
149+
JsonSerializer.SerializeToElement(new Dictionary<string, object?>
150+
{
151+
["type"] = messageType,
152+
["session_id"] = sessionId,
153+
["text"] = ExtractText(updateElement),
154+
["update"] = updateElement
155+
}));
156+
}
157+
158+
private static CliMessage CreateUpdateMessage(string messageType, string sessionId, JsonElement updateElement)
159+
{
160+
return new CliMessage(
161+
messageType,
162+
JsonSerializer.SerializeToElement(new Dictionary<string, object?>
163+
{
164+
["type"] = messageType,
165+
["session_id"] = sessionId,
166+
["update"] = updateElement
167+
}));
168+
}
169+
170+
private static CliMessage CreatePromptCompletedMessage(string sessionId, JsonElement updateElement)
171+
{
172+
var stopReason = TryGetString(updateElement, "stopReason");
173+
var messageType = IsFailureStopReason(stopReason) ? "terminal.failed" : "terminal.completed";
174+
return new CliMessage(
175+
messageType,
176+
JsonSerializer.SerializeToElement(new Dictionary<string, object?>
177+
{
178+
["type"] = messageType,
179+
["session_id"] = sessionId,
180+
["stop_reason"] = stopReason,
181+
["update"] = updateElement
182+
}));
183+
}
184+
185+
private static string? ExtractText(JsonElement updateElement)
186+
{
187+
if (!updateElement.TryGetProperty("content", out var contentElement))
188+
{
189+
return null;
190+
}
191+
192+
return ExtractTextFromContent(contentElement);
193+
}
194+
195+
private static string? ExtractTextFromContent(JsonElement contentElement)
196+
{
197+
return contentElement.ValueKind switch
198+
{
199+
JsonValueKind.String => contentElement.GetString(),
200+
JsonValueKind.Object => ExtractTextFromObject(contentElement),
201+
JsonValueKind.Array => ExtractTextFromArray(contentElement),
202+
_ => null
203+
};
204+
}
205+
206+
private static string? ExtractTextFromObject(JsonElement contentElement)
207+
{
208+
if (TryGetString(contentElement, "text") is { Length: > 0 } directText)
209+
{
210+
return directText;
211+
}
212+
213+
if (contentElement.TryGetProperty("content", out var nestedContent))
214+
{
215+
return ExtractTextFromContent(nestedContent);
216+
}
217+
218+
return null;
219+
}
220+
221+
private static string? ExtractTextFromArray(JsonElement contentElement)
222+
{
223+
var parts = new List<string>();
224+
foreach (var item in contentElement.EnumerateArray())
225+
{
226+
var text = ExtractTextFromContent(item);
227+
if (!string.IsNullOrWhiteSpace(text))
228+
{
229+
parts.Add(text);
230+
}
231+
}
232+
233+
return parts.Count == 0 ? null : string.Concat(parts);
234+
}
235+
236+
private static string? TryGetPromptResultStopReason(JsonElement promptResult)
237+
{
238+
return TryGetString(promptResult, "stopReason") ?? TryGetString(promptResult, "status");
239+
}
240+
241+
private static string? TryGetString(JsonElement element, string propertyName)
242+
{
243+
return element.TryGetProperty(propertyName, out var propertyElement) &&
244+
propertyElement.ValueKind == JsonValueKind.String
245+
? propertyElement.GetString()
246+
: null;
247+
}
248+
249+
private static bool IsFailureStopReason(string? stopReason)
250+
{
251+
return string.Equals(stopReason, "error", StringComparison.OrdinalIgnoreCase) ||
252+
string.Equals(stopReason, "failed", StringComparison.OrdinalIgnoreCase) ||
253+
string.Equals(stopReason, "cancelled", StringComparison.OrdinalIgnoreCase);
254+
}
255+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
namespace HagiCode.Libs.Providers.Codebuddy;
2+
3+
/// <summary>
4+
/// Describes a CodeBuddy ACP CLI invocation.
5+
/// </summary>
6+
public sealed record CodebuddyOptions
7+
{
8+
/// <summary>
9+
/// Gets or sets the custom CodeBuddy executable path.
10+
/// </summary>
11+
public string? ExecutablePath { get; init; }
12+
13+
/// <summary>
14+
/// Gets or sets the working directory bound to the ACP session.
15+
/// </summary>
16+
public string? WorkingDirectory { get; init; }
17+
18+
/// <summary>
19+
/// Gets or sets the CodeBuddy model override.
20+
/// </summary>
21+
public string? Model { get; init; }
22+
23+
/// <summary>
24+
/// Gets or sets the session identifier to reuse.
25+
/// </summary>
26+
public string? SessionId { get; init; }
27+
28+
/// <summary>
29+
/// Gets a value indicating whether session reuse was requested.
30+
/// </summary>
31+
public bool ReuseSession => !string.IsNullOrWhiteSpace(SessionId);
32+
33+
/// <summary>
34+
/// Gets or sets the ACP bootstrap timeout.
35+
/// </summary>
36+
public TimeSpan? StartupTimeout { get; init; }
37+
38+
/// <summary>
39+
/// Gets or sets environment variables injected into the CodeBuddy process.
40+
/// </summary>
41+
public IReadOnlyDictionary<string, string?> EnvironmentVariables { get; init; } = new Dictionary<string, string?>();
42+
43+
/// <summary>
44+
/// Gets or sets additional raw CLI arguments appended after the ACP bootstrap switch.
45+
/// </summary>
46+
public IReadOnlyList<string> ExtraArguments { get; init; } = [];
47+
}

0 commit comments

Comments
 (0)