Skip to content

Commit 9289bf4

Browse files
authored
Merge branch 'main' into dependabot/nuget/src/CodePunk.Console/multi-2af7ad6f9e
2 parents c801232 + 41c9526 commit 9289bf4

21 files changed

+880
-65
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,19 @@ Files & directories:
287287

288288
See `ROADMAP.md` for upcoming enhancements (model listing, provider extensions, improved tool system).
289289

290+
### Open Backlog Items
291+
292+
The following technical tasks were identified but not completed in this change set:
293+
294+
- Add Spectre.Console output capture helper for asserting error/markup content in CLI scenario tests.
295+
- Add test covering `ChatSessionEventType.ToolLoopExceeded` (tool loop exceeded event) in streaming and non-stream paths.
296+
- Consolidate `TrimTitle` logic (currently implemented separately in `NewCommand` and `RootCommandFactory`).
297+
- Extract a shared test host factory for Core chat unit tests (similar to `ConsoleTestHostFactory`).
298+
- Introduce streaming fallback test verifying max tool iteration fallback in streaming API (`SendMessageStreamAsync`).
299+
- Eliminate remaining analyzer warnings (e.g. nullable dereference in `NewCommandTests`, any residual async warnings).
300+
- Provide documentation for run command agent/model override precedence and tool iteration/fallback behavior.
301+
- Optional: Add quiet/diagnostic toggle to reduce console noise during test runs.
302+
290303
### Areas for Contribution
291304
- **AI Provider Integration**: Add local models, Azure OpenAI, or other providers
292305
- **Tool Development**: Create tools for specific languages or frameworks

ROADMAP.md

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,24 @@
33
Status legend: ✅ done · 🔄 in-progress · ⏳ planned · 🧪 needs tests · 🧹 refactor · ❗ open decision
44

55
## 1. CLI & UX
6-
- Enhance `models` command: enumerate real models from providers (OpenAI / Anthropic) instead of placeholder
7-
- Add `--json` output option for `run`, `auth list`, `agent list`, `models`
8-
- Graceful error when no providers / API keys configured (clear guidance to run `codepunk auth login`)
6+
- Enhance `models` command: enumerate real models (dynamic fetch + fallback) & show key presence (9 Sep 2025)
7+
- 🧪 Add `--json` output option for `run`, `auth list`, `agent list` (partial: `models` already supports)
8+
- Graceful error when no providers / API keys configured (guidance message implemented)
99
- ⏳ Root-level `sessions` management (list, show, load) mirrored from interactive commands
1010
- ⏳ Add `--provider` / `--model` flags to `run` that override agent defaults (already partially supported internally, needs docs & tests)
1111
- ⏳ Interactive: command autocompletion (tab) & history persistence
1212

1313
## 2. Chat / Session Core
14-
- 🔄 Temporary timing fix uses `Task.Delay(1)` to surface `IsProcessing`; replace with event-based or `IProgress` notification (remove artificial delay)
14+
- ✅ Replaced artificial `Task.Delay(1)` with channel-based event stream (MessageStart/Complete, ToolIteration*, StreamDelta) (9 Sep 2025)
1515
- ⏳ Session pruning / archive strategy (size limits, rotation)
1616
- ⏳ Export session to markdown / JSON command
1717
- ⏳ Import session from JSON file
1818

1919
## 3. Providers & Models
20-
- Azure OpenAI provider implementation
20+
- 🧪 Azure OpenAI provider implementation (pending)
2121
- ⏳ Local model provider(s): Ollama + LM Studio
2222
- ⏳ Dynamic provider discovery via configuration section scanning
23-
- Model capability metadata (max tokens, supports tools, streaming) exposed to UX
23+
- Model capability metadata surfaced (context/max/tools/streaming columns + JSON) (9 Sep 2025)
2424

2525
## 4. Tooling System
2626
- ⏳ Add file search / grep tool (fast code reference)
@@ -50,17 +50,18 @@ Status legend: ✅ done · 🔄 in-progress · ⏳ planned · 🧪 needs tests
5050

5151
## 9. Testing Strategy
5252
- ✅ Added DI resolution test for interactive loop & renderer
53-
- 🔄 Need scenario tests:
54-
- 🧪 `run` command: new session creation, `--continue`, `--session`, conflict of `--continue` + `--session`
53+
- ✅ Models command auth state tests (hasKey, filter, JSON hasKey) (9 Sep 2025)
54+
- ✅ Event stream ordering & streaming delta tests (9 Sep 2025)
55+
- 🔄 Remaining scenario tests:
56+
- 🧪 `run` command: new session creation, `--continue`, `--session`, conflict handling
5557
- 🧪 Agent override precedence (agent model vs `--model` flag)
56-
- 🧪 Models command output with authenticated vs unauthenticated state
57-
- 🧪 Auth / Agent command round-trip snapshot (create/list/show/delete)
58-
- 🧪 Root invocation with no args enters interactive mode (detect via injected test console abstraction)
58+
- 🧪 Auth / Agent round-trip snapshot
59+
- 🧪 Root invocation no-args interactive detection
5960
- ⏳ Provider missing key error path tests
6061
- ⏳ Performance regression micro-benchmarks (streaming throughput)
6162

6263
## 10. Refactors / Tech Debt
63-
- 🧹 Extract Program.cs service registrations into `AddCodePunkConsole()` extension
64+
- ✅ Extracted Program.cs service registrations into `AddCodePunkConsole()` extension (9 Sep 2025)
6465
- 🧹 Introduce `IInteractiveChatLoop` interface (simplify mocking / test harness)
6566
- 🧹 Collapse duplicated test host bootstrapping into shared factory
6667
- 🧹 Consolidate file store persistence patterns (tmp + atomic move) into utility
@@ -90,14 +91,15 @@ Status legend: ✅ done · 🔄 in-progress · ⏳ planned · 🧪 needs tests
9091
---
9192

9293
### Immediate Next Sprint Candidates
93-
1. Replace `Task.Delay(1)` with event-driven processing state (Chat session stabilization)
94-
2. Real model listing + provider key validation in `models` command
95-
3. `run` command scenario & conflict tests
96-
4. Auth / Agent snapshot tests
97-
5. Refactor DI registrations into extension method
94+
1. `run` command scenario & conflict tests
95+
2. Agent override precedence tests
96+
3. Auth / Agent snapshot tests
97+
4. Config paths command
98+
5. Provider missing key error tests & Azure OpenAI provider spike
9899

99100
### Notes
100-
- Current test stats: 93 total (92 passing, 1 skipped) after DI / renderer registration fix.
101+
- Current test stats: 108 total (107 passing, 1 skipped) after event stream + models + DI refactor.
101102
- Temporary heuristics: token estimation via char/4; upgrade to tokenizer libs later.
103+
- Channel event stream now source of truth for processing state; future UI can subscribe.
102104

103105
Feel free to append inline decisions or sign off on completed items using initials + date.

src/CodePunk.Console/CodePunk.Console.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
<PackageReference Include="Spectre.Console" Version="0.50.0" />
1818
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
1919
<PackageReference Include="OpenTelemetry" Version="1.12.0" />
20-
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.8.1" />
2120
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
21+
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
2222
</ItemGroup>
2323

2424
<ItemGroup>

src/CodePunk.Console/Commands/BuiltInCommands.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,18 @@ public class NewCommand : ChatCommand
6363
public override string Description => "Start a new chat session";
6464
public override string[] Aliases => ["n"];
6565

66+
private static string TrimTitle(string title)
67+
{
68+
if (string.IsNullOrWhiteSpace(title)) return title;
69+
const int max = 80;
70+
var trimmed = title.Trim();
71+
return trimmed.Length > max ? trimmed[..max] : trimmed;
72+
}
73+
6674
public override Task<CommandResult> ExecuteAsync(string[] args, CancellationToken cancellationToken = default)
6775
{
6876
var sessionTitle = args.Length > 0
69-
? string.Join(" ", args)
77+
? TrimTitle(string.Join(" ", args))
7078
: $"Chat Session {DateTime.Now:yyyy-MM-dd HH:mm}";
7179
return Task.FromResult(CommandResult.ClearSession($"{ConsoleStyles.Success("✓")} New session: {ConsoleStyles.Accent(sessionTitle)}"));
7280
}

src/CodePunk.Console/Commands/RootCommandFactory.cs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ namespace CodePunk.Console.Commands;
1515

1616
internal static class RootCommandFactory
1717
{
18+
private const int MaxTitleLength = 80;
19+
private static string TrimTitle(string title)
20+
{
21+
if (string.IsNullOrWhiteSpace(title)) return title;
22+
var trimmed = title.Trim();
23+
return trimmed.Length > MaxTitleLength ? trimmed[..MaxTitleLength] : trimmed;
24+
}
1825
public static RootCommand Create(IServiceProvider services)
1926
{
2027
var root = new RootCommand("CodePunk CLI")
@@ -79,13 +86,14 @@ private static Command BuildRun(IServiceProvider services)
7986
}
8087
}
8188
services.GetRequiredService<InteractiveChatSession>().UpdateDefaults(providerOverride, modelOverride);
89+
var resolvedModelForStore = modelOverride ?? (string.IsNullOrWhiteSpace(model) ? null : model);
8290
if (string.IsNullOrWhiteSpace(sessionId))
8391
{
84-
sessionId = await sessionStore.CreateAsync(TrimTitle(message), agent, model);
92+
sessionId = await sessionStore.CreateAsync(TrimTitle(message), agent, resolvedModelForStore);
8593
}
8694
else if (await sessionStore.GetAsync(sessionId) == null)
8795
{
88-
sessionId = await sessionStore.CreateAsync(TrimTitle(message), agent, model);
96+
sessionId = await sessionStore.CreateAsync(TrimTitle(message), agent, resolvedModelForStore);
8997
}
9098
await sessionStore.AppendMessageAsync(sessionId, "user", message);
9199
var response = await chatLoop.RunSingleAsync(message);
@@ -239,17 +247,23 @@ private static Command BuildAgent(IServiceProvider services)
239247
private static Command BuildModels(IServiceProvider services)
240248
{
241249
var jsonOpt = new Option<bool>("--json", "Output JSON");
242-
var cmd = new Command("models", "List available models from configured providers") { jsonOpt };
250+
var availableOnlyOpt = new Option<bool>("--available-only", "Show only providers with stored API keys");
251+
var cmd = new Command("models", "List available models from configured providers") { jsonOpt, availableOnlyOpt };
243252
cmd.SetHandler(async (InvocationContext ctx) =>
244253
{
245254
try
246255
{
247256
var json = ctx.ParseResult.GetValueForOption(jsonOpt);
257+
var availableOnly = ctx.ParseResult.GetValueForOption(availableOnlyOpt);
248258
var llm = services.GetRequiredService<ILLMService>();
249259
var providers = llm.GetProviders() ?? Array.Empty<ILLMProvider>();
250-
var rows = new List<(string Provider,string Id,string Name,int Context,int MaxTokens,bool Tools,bool Streaming)>();
260+
var authStore = services.GetRequiredService<IAuthStore>();
261+
var authenticated = (await authStore.LoadAsync()).Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
262+
var rows = new List<(string Provider,string Id,string Name,int Context,int MaxTokens,bool Tools,bool Streaming,bool HasKey)>();
251263
foreach (var p in providers.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
252264
{
265+
var hasKey = authenticated.Contains(p.Name) || authenticated.Contains(p.Name.Replace("Provider","", StringComparison.OrdinalIgnoreCase));
266+
if (availableOnly && !hasKey) continue;
253267
IReadOnlyList<CodePunk.Core.Abstractions.LLMModel> remote = Array.Empty<CodePunk.Core.Abstractions.LLMModel>();
254268
try
255269
{
@@ -259,12 +273,12 @@ private static Command BuildModels(IServiceProvider services)
259273

260274
var models = (remote != null && remote.Count > 0) ? remote : p.Models;
261275
foreach (var m in models.OrderBy(m => m.Id, StringComparer.OrdinalIgnoreCase))
262-
rows.Add((p.Name, m.Id, m.Name, m.ContextWindow, m.MaxTokens, m.SupportsTools, m.SupportsStreaming));
276+
rows.Add((p.Name, m.Id, m.Name, m.ContextWindow, m.MaxTokens, m.SupportsTools, m.SupportsStreaming, hasKey));
263277
}
264278
var writer = ctx.Console.Out;
265279
if (json)
266280
{
267-
var jsonOut = System.Text.Json.JsonSerializer.Serialize(rows.Select(r => new { provider = r.Provider, id = r.Id, name = r.Name, context = r.Context, maxTokens = r.MaxTokens, tools = r.Tools, streaming = r.Streaming }), new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
281+
var jsonOut = System.Text.Json.JsonSerializer.Serialize(rows.Select(r => new { provider = r.Provider, id = r.Id, name = r.Name, context = r.Context, maxTokens = r.MaxTokens, tools = r.Tools, streaming = r.Streaming, hasKey = r.HasKey }), new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
268282
writer.Write(jsonOut + "\n");
269283
return;
270284
}
@@ -290,9 +304,11 @@ private static Command BuildModels(IServiceProvider services)
290304
table.AddColumn(new TableColumn("Max").Centered());
291305
table.AddColumn(new TableColumn("Tools").Centered());
292306
table.AddColumn(new TableColumn("Stream").Centered());
307+
table.AddColumn(new TableColumn("Key").Centered());
293308
foreach (var r in rows)
294309
{
295-
table.AddRow(ConsoleStyles.Accent(r.Provider), r.Id, r.Name, r.Context.ToString(), r.MaxTokens.ToString(), r.Tools?"[green]✓[/]":"[grey]-[/]", r.Streaming?"[green]✓[/]":"[grey]-[/]");
310+
var providerLabel = r.HasKey ? ConsoleStyles.Accent(r.Provider) : $"[grey]{r.Provider}[/]";
311+
table.AddRow(providerLabel, r.Id, r.Name, r.Context.ToString(), r.MaxTokens.ToString(), r.Tools?"[green]✓[/]":"[grey]-[/]", r.Streaming?"[green]✓[/]":"[grey]-[/]", r.HasKey?"[green]✓[/]":"[red]✗[/]");
296312
writer.Write($"{r.Provider}\t{r.Id}\t{r.Name}\n");
297313
}
298314
console?.Write(table);
@@ -356,9 +372,4 @@ internal static Command CreateModelsCommandForTests(IServiceProvider services)
356372
return cmd;
357373
}
358374

359-
private static string TrimTitle(string input)
360-
{
361-
var oneLine = input.Replace("\n", " ").Trim();
362-
return oneLine.Length <= 60 ? oneLine : oneLine[..60];
363-
}
364375
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using CodePunk.Console.Chat;
2+
using CodePunk.Console.Commands;
3+
using CodePunk.Console.Rendering;
4+
using CodePunk.Console.Stores;
5+
using CodePunk.Core.Chat;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Spectre.Console;
8+
9+
namespace CodePunk.Console.Configuration;
10+
11+
public static class ConsoleServiceCollectionExtensions
12+
{
13+
public static IServiceCollection AddCodePunkConsole(this IServiceCollection services)
14+
{
15+
services.AddSingleton<IAnsiConsole>(AnsiConsole.Console);
16+
services.AddSingleton<IAuthStore, AuthFileStore>();
17+
services.AddSingleton<IAgentStore, AgentFileStore>();
18+
services.AddSingleton<ISessionFileStore, SessionFileStore>();
19+
20+
services.AddScoped<InteractiveChatLoop>();
21+
services.AddSingleton(new StreamingRendererOptions { LiveEnabled = false });
22+
services.AddSingleton<StreamingResponseRenderer>();
23+
24+
services.AddTransient<ChatCommand, HelpCommand>();
25+
services.AddTransient<ChatCommand, NewCommand>();
26+
services.AddTransient<ChatCommand, QuitCommand>();
27+
services.AddTransient<ChatCommand, ClearCommand>();
28+
services.AddTransient<ChatCommand, SessionsCommand>();
29+
services.AddTransient<ChatCommand, LoadCommand>();
30+
services.AddTransient<ChatCommand, UseCommand>();
31+
services.AddTransient<ChatCommand, UsageCommand>();
32+
services.AddTransient<ChatCommand, ModelsChatCommand>();
33+
services.AddSingleton<CommandProcessor>();
34+
return services;
35+
}
36+
}

src/CodePunk.Console/Program.cs

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using CodePunk.Console.Chat;
22
using CodePunk.Console.Commands;
33
using CodePunk.Console.Rendering;
4+
using CodePunk.Console.Configuration;
45
using CodePunk.Console.Stores;
56
using CodePunk.Infrastructure.Configuration;
67
using CodePunk.Console.Themes;
@@ -46,25 +47,7 @@
4647

4748
builder.Services.AddCodePunkServices(builder.Configuration);
4849

49-
builder.Services.AddSingleton<IAnsiConsole>(Spectre.Console.AnsiConsole.Console);
50-
builder.Services.AddSingleton<IAuthStore, AuthFileStore>();
51-
builder.Services.AddSingleton<IAgentStore, AgentFileStore>();
52-
builder.Services.AddSingleton<ISessionFileStore, SessionFileStore>();
53-
54-
builder.Services.AddScoped<InteractiveChatLoop>();
55-
builder.Services.AddSingleton(new StreamingRendererOptions { LiveEnabled = false });
56-
builder.Services.AddSingleton<StreamingResponseRenderer>();
57-
58-
builder.Services.AddTransient<ChatCommand, HelpCommand>();
59-
builder.Services.AddTransient<ChatCommand, NewCommand>();
60-
builder.Services.AddTransient<ChatCommand, QuitCommand>();
61-
builder.Services.AddTransient<ChatCommand, ClearCommand>();
62-
builder.Services.AddTransient<ChatCommand, SessionsCommand>();
63-
builder.Services.AddTransient<ChatCommand, LoadCommand>();
64-
builder.Services.AddTransient<ChatCommand, UseCommand>();
65-
builder.Services.AddTransient<ChatCommand, UsageCommand>();
66-
builder.Services.AddTransient<ChatCommand, ModelsChatCommand>();
67-
builder.Services.AddSingleton<CommandProcessor>();
50+
builder.Services.AddCodePunkConsole();
6851

6952
var host = builder.Build();
7053
await host.Services.EnsureDatabaseCreatedAsync();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Threading.Channels;
2+
3+
namespace CodePunk.Core.Chat;
4+
5+
public enum ChatSessionEventType
6+
{
7+
MessageStart,
8+
StreamDelta,
9+
MessageComplete,
10+
ToolIterationStart,
11+
ToolIterationEnd,
12+
ToolLoopExceeded,
13+
SessionCleared
14+
}
15+
16+
public record ChatSessionEvent(
17+
ChatSessionEventType Type,
18+
string? SessionId = null,
19+
int? Iteration = null,
20+
string? Delta = null,
21+
bool? IsFinal = null,
22+
DateTime? Utc = null)
23+
{
24+
public DateTime Timestamp => Utc ?? DateTime.UtcNow;
25+
}
26+
27+
public interface IChatSessionEventStream
28+
{
29+
ChannelReader<ChatSessionEvent> Reader { get; }
30+
bool TryWrite(ChatSessionEvent evt);
31+
}
32+
33+
public class ChatSessionEventStream : IChatSessionEventStream
34+
{
35+
private readonly Channel<ChatSessionEvent> _channel;
36+
37+
public ChatSessionEventStream()
38+
{
39+
_channel = Channel.CreateUnbounded<ChatSessionEvent>(new UnboundedChannelOptions
40+
{
41+
SingleWriter = true,
42+
AllowSynchronousContinuations = true
43+
});
44+
}
45+
46+
public ChannelReader<ChatSessionEvent> Reader => _channel.Reader;
47+
48+
public bool TryWrite(ChatSessionEvent evt)
49+
{
50+
return _channel.Writer.TryWrite(evt);
51+
}
52+
}

0 commit comments

Comments
 (0)