Skip to content

Commit 86f0a89

Browse files
committed
fix: stabilize provider metadata and hydration startup
1 parent 71498c5 commit 86f0a89

6 files changed

Lines changed: 242 additions & 47 deletions

File tree

DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -806,24 +806,7 @@ private async Task EnsureDefaultProviderAndAgentAsync(
806806
.AnyAsync(record => record.IsEnabled, cancellationToken);
807807
if (!hasEnabledProvider)
808808
{
809-
var debugPreference = await dbContext.ProviderPreferences
810-
.FirstOrDefaultAsync(
811-
record => record.ProviderKind == (int)AgentProviderKind.Debug,
812-
cancellationToken);
813-
814-
if (debugPreference is null)
815-
{
816-
debugPreference = new ProviderPreferenceRecord
817-
{
818-
ProviderKind = (int)AgentProviderKind.Debug,
819-
};
820-
dbContext.ProviderPreferences.Add(debugPreference);
821-
}
822-
823-
debugPreference.IsEnabled = true;
824-
debugPreference.UpdatedAt = timeProvider.GetUtcNow();
825-
AgentSessionServiceLog.DefaultProviderEnabled(logger, AgentProviderKind.Debug);
826-
await dbContext.SaveChangesAsync(cancellationToken);
809+
await EnsureProviderEnabledAsync(dbContext, AgentProviderKind.Debug, cancellationToken);
827810
}
828811

829812
await NormalizeLegacyAgentProfilesAsync(dbContext, cancellationToken);
@@ -834,13 +817,12 @@ record => record.ProviderKind == (int)AgentProviderKind.Debug,
834817
return;
835818
}
836819

837-
var providerSnapshot = await GetProviderStatusesAsync(forceRefresh: true, cancellationToken);
838-
var providerKind = providerSnapshot
839-
.Where(provider =>
840-
provider.CanCreateAgents &&
841-
AgentSessionProviderCatalog.Get(provider.Kind).SupportsLiveExecution)
842-
.Select(static provider => provider.Kind)
843-
.FirstOrDefault(AgentProviderKind.Debug);
820+
var providerKind = await ResolveSeedProviderKindAsync(dbContext, cancellationToken);
821+
if (providerKind == AgentProviderKind.Debug)
822+
{
823+
await EnsureProviderEnabledAsync(dbContext, providerKind, cancellationToken);
824+
}
825+
844826
var record = new AgentProfileRecord
845827
{
846828
Id = Guid.CreateVersion7(),
@@ -859,6 +841,62 @@ record => record.ProviderKind == (int)AgentProviderKind.Debug,
859841
AgentSessionServiceLog.DefaultAgentSeeded(logger, record.Id, providerKind, record.ModelName);
860842
}
861843

844+
private async Task EnsureProviderEnabledAsync(
845+
LocalAgentSessionDbContext dbContext,
846+
AgentProviderKind providerKind,
847+
CancellationToken cancellationToken)
848+
{
849+
var preference = await dbContext.ProviderPreferences
850+
.FirstOrDefaultAsync(
851+
record => record.ProviderKind == (int)providerKind,
852+
cancellationToken);
853+
854+
if (preference is null)
855+
{
856+
preference = new ProviderPreferenceRecord
857+
{
858+
ProviderKind = (int)providerKind,
859+
};
860+
dbContext.ProviderPreferences.Add(preference);
861+
}
862+
863+
if (preference.IsEnabled)
864+
{
865+
return;
866+
}
867+
868+
preference.IsEnabled = true;
869+
preference.UpdatedAt = timeProvider.GetUtcNow();
870+
AgentSessionServiceLog.DefaultProviderEnabled(logger, providerKind);
871+
await dbContext.SaveChangesAsync(cancellationToken);
872+
}
873+
874+
private static async ValueTask<AgentProviderKind> ResolveSeedProviderKindAsync(
875+
LocalAgentSessionDbContext dbContext,
876+
CancellationToken cancellationToken)
877+
{
878+
var enabledProviderKinds = await dbContext.ProviderPreferences
879+
.Where(record => record.IsEnabled)
880+
.Select(record => (AgentProviderKind)record.ProviderKind)
881+
.ToArrayAsync(cancellationToken);
882+
883+
foreach (var providerKind in enabledProviderKinds)
884+
{
885+
var profile = AgentSessionProviderCatalog.Get(providerKind);
886+
if (!profile.SupportsLiveExecution || profile.IsBuiltIn)
887+
{
888+
continue;
889+
}
890+
891+
if (!string.IsNullOrWhiteSpace(AgentSessionCommandProbe.ResolveExecutablePath(profile.CommandName)))
892+
{
893+
return providerKind;
894+
}
895+
}
896+
897+
return AgentProviderKind.Debug;
898+
}
899+
862900
private async ValueTask<IReadOnlyList<ProviderStatusDescriptor>> GetProviderStatusesAsync(
863901
bool forceRefresh,
864902
CancellationToken cancellationToken)

DotPilot.Core/Providers/Services/ClaudeCodeCliMetadataReader.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,6 @@ public static ProviderCliMetadataSnapshot TryRead(string executablePath, AgentSe
7171

7272
private static string GetSettingsPath()
7373
{
74-
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
75-
return string.IsNullOrWhiteSpace(homePath)
76-
? string.Empty
77-
: Path.Combine(homePath, ".claude", SettingsFileName);
74+
return ProviderCliHomeDirectory.GetFilePath(".claude", SettingsFileName);
7875
}
7976
}

DotPilot.Core/Providers/Services/CodexCliMetadataReader.cs

Lines changed: 139 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
1+
using System.Text.Json;
12
using ManagedCode.CodexSharpSDK.Client;
23
using ManagedCode.CodexSharpSDK.Configuration;
34

45
namespace DotPilot.Core.Providers;
56

67
internal static class CodexCliMetadataReader
78
{
9+
private const string ConfigDirectoryName = ".codex";
10+
private const string ConfigFileName = "config.toml";
11+
private const string ModelsCacheFileName = "models_cache.json";
12+
private const string DefaultModelPropertyName = "model";
813
private const string VersionSeparator = "version";
914

1015
public static CodexCliMetadataSnapshot? TryRead(string executablePath)
1116
{
1217
ArgumentException.ThrowIfNullOrWhiteSpace(executablePath);
1318

19+
var fallbackSnapshot = TryReadFromLocalFiles();
1420
try
1521
{
1622
using var client = new CodexClient(new CodexOptions
@@ -20,18 +26,28 @@ internal static class CodexCliMetadataReader
2026
var metadata = client.GetCliMetadata();
2127
return new CodexCliMetadataSnapshot(
2228
NormalizeInstalledVersion(metadata.InstalledVersion),
23-
metadata.DefaultModel,
24-
metadata.Models
25-
.Where(static model => model.IsListed)
26-
.Select(static model => model.Slug)
27-
.ToArray());
29+
ResolveDefaultModel(fallbackSnapshot?.DefaultModel, metadata.DefaultModel),
30+
MergeAvailableModels(
31+
fallbackSnapshot?.AvailableModels ?? [],
32+
metadata.Models
33+
.Where(static model => model.IsListed)
34+
.Select(static model => model.Slug)));
2835
}
2936
catch
3037
{
31-
return null;
38+
return fallbackSnapshot;
3239
}
3340
}
3441

42+
private static CodexCliMetadataSnapshot? TryReadFromLocalFiles()
43+
{
44+
var defaultModel = TryReadDefaultModelFromConfig();
45+
var models = TryReadAvailableModelsFromCache();
46+
return string.IsNullOrWhiteSpace(defaultModel) && models.Length == 0
47+
? null
48+
: new CodexCliMetadataSnapshot(null, defaultModel, models);
49+
}
50+
3551
private static string? NormalizeInstalledVersion(string? installedVersion)
3652
{
3753
if (string.IsNullOrWhiteSpace(installedVersion))
@@ -52,6 +68,123 @@ internal static class CodexCliMetadataReader
5268
? firstLine[(separatorIndex + VersionSeparator.Length)..].Trim(' ', ':')
5369
: firstLine.Trim();
5470
}
71+
72+
private static string? ResolveDefaultModel(string? configuredModel, string? discoveredModel)
73+
{
74+
return string.IsNullOrWhiteSpace(configuredModel)
75+
? discoveredModel
76+
: configuredModel;
77+
}
78+
79+
private static string[] MergeAvailableModels(
80+
IReadOnlyList<string> configuredModels,
81+
IEnumerable<string> discoveredModels)
82+
{
83+
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
84+
List<string> models = [];
85+
86+
foreach (var model in configuredModels.Concat(discoveredModels))
87+
{
88+
if (string.IsNullOrWhiteSpace(model) || !seen.Add(model))
89+
{
90+
continue;
91+
}
92+
93+
models.Add(model);
94+
}
95+
96+
return [.. models];
97+
}
98+
99+
private static string? TryReadDefaultModelFromConfig()
100+
{
101+
var configPath = ProviderCliHomeDirectory.GetFilePath(ConfigDirectoryName, ConfigFileName);
102+
if (string.IsNullOrWhiteSpace(configPath) || !File.Exists(configPath))
103+
{
104+
return null;
105+
}
106+
107+
try
108+
{
109+
foreach (var line in File.ReadLines(configPath))
110+
{
111+
if (!TryParseConfigValue(line, DefaultModelPropertyName, out var value))
112+
{
113+
continue;
114+
}
115+
116+
return value;
117+
}
118+
}
119+
catch
120+
{
121+
}
122+
123+
return null;
124+
}
125+
126+
private static string[] TryReadAvailableModelsFromCache()
127+
{
128+
var cachePath = ProviderCliHomeDirectory.GetFilePath(ConfigDirectoryName, ModelsCacheFileName);
129+
if (string.IsNullOrWhiteSpace(cachePath) || !File.Exists(cachePath))
130+
{
131+
return [];
132+
}
133+
134+
try
135+
{
136+
using var stream = File.OpenRead(cachePath);
137+
using var document = JsonDocument.Parse(stream);
138+
if (!document.RootElement.TryGetProperty("models", out var modelsElement) ||
139+
modelsElement.ValueKind != JsonValueKind.Array)
140+
{
141+
return [];
142+
}
143+
144+
return modelsElement.EnumerateArray()
145+
.Select(static model => model.TryGetProperty("slug", out var slugProperty)
146+
? slugProperty.GetString()
147+
: null)
148+
.Where(static slug => !string.IsNullOrWhiteSpace(slug))
149+
.Cast<string>()
150+
.ToArray();
151+
}
152+
catch
153+
{
154+
return [];
155+
}
156+
}
157+
158+
private static bool TryParseConfigValue(string line, string propertyName, out string value)
159+
{
160+
value = string.Empty;
161+
if (string.IsNullOrWhiteSpace(line))
162+
{
163+
return false;
164+
}
165+
166+
var trimmedLine = line.Trim();
167+
if (!trimmedLine.StartsWith(propertyName, StringComparison.Ordinal) ||
168+
!trimmedLine.Contains('=', StringComparison.Ordinal))
169+
{
170+
return false;
171+
}
172+
173+
var separatorIndex = trimmedLine.IndexOf('=', StringComparison.Ordinal);
174+
var candidate = trimmedLine[(separatorIndex + 1)..].Trim();
175+
if (candidate.StartsWith('"') && candidate.EndsWith('"') && candidate.Length >= 2)
176+
{
177+
candidate = candidate[1..^1];
178+
}
179+
180+
if (string.IsNullOrWhiteSpace(candidate))
181+
{
182+
return false;
183+
}
184+
185+
value = candidate;
186+
return true;
187+
}
55188
}
56189

57190
internal sealed record CodexCliMetadataSnapshot(

DotPilot.Core/Providers/Services/CopilotCliMetadataReader.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,6 @@ private static IReadOnlyList<string> ReadSupportedModelsFromHelp(string executab
123123

124124
private static string GetConfigPath()
125125
{
126-
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
127-
return string.IsNullOrWhiteSpace(homePath)
128-
? string.Empty
129-
: Path.Combine(homePath, ".copilot", ConfigFileName);
126+
return ProviderCliHomeDirectory.GetFilePath(".copilot", ConfigFileName);
130127
}
131128
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace DotPilot.Core.Providers;
2+
3+
internal static class ProviderCliHomeDirectory
4+
{
5+
public static string GetPath()
6+
{
7+
foreach (var variableName in VariableNames)
8+
{
9+
var value = Environment.GetEnvironmentVariable(variableName);
10+
if (!string.IsNullOrWhiteSpace(value))
11+
{
12+
return value;
13+
}
14+
}
15+
16+
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
17+
}
18+
19+
public static string GetFilePath(string directoryName, string fileName)
20+
{
21+
ArgumentException.ThrowIfNullOrWhiteSpace(directoryName);
22+
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
23+
24+
var homePath = GetPath();
25+
return string.IsNullOrWhiteSpace(homePath)
26+
? string.Empty
27+
: Path.Combine(homePath, directoryName, fileName);
28+
}
29+
30+
private static readonly string[] VariableNames =
31+
[
32+
"HOME",
33+
"USERPROFILE",
34+
];
35+
}

DotPilot.Tests/Workspace/Services/StartupWorkspaceHydrationTests.cs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,10 @@ public async Task EnsureHydratedAsyncKeepsHydrationRetryableAfterATransientWorks
4242
"DotPilot.Tests",
4343
nameof(StartupWorkspaceHydrationTests),
4444
Guid.NewGuid().ToString("N"));
45-
var blockedDirectoryPath = Path.Combine(rootPath, "blocked");
46-
var databasePath = Path.Combine(blockedDirectoryPath, "dotpilot-agent-sessions.db");
45+
var databasePath = Path.Combine(rootPath, "dotpilot-agent-sessions.db");
4746

4847
Directory.CreateDirectory(rootPath);
49-
Directory.CreateDirectory(blockedDirectoryPath);
48+
Directory.CreateDirectory(databasePath);
5049

5150
try
5251
{
@@ -56,16 +55,12 @@ public async Task EnsureHydratedAsyncKeepsHydrationRetryableAfterATransientWorks
5655
});
5756
var hydration = fixture.Provider.GetRequiredService<IStartupWorkspaceHydration>();
5857

59-
Directory.Delete(blockedDirectoryPath);
60-
await File.WriteAllTextAsync(blockedDirectoryPath, "blocked");
61-
6258
await hydration.EnsureHydratedAsync(CancellationToken.None);
6359

6460
hydration.IsHydrating.Should().BeFalse();
6561
hydration.IsReady.Should().BeFalse();
6662

67-
File.Delete(blockedDirectoryPath);
68-
Directory.CreateDirectory(blockedDirectoryPath);
63+
Directory.Delete(databasePath);
6964

7065
await hydration.EnsureHydratedAsync(CancellationToken.None);
7166

0 commit comments

Comments
 (0)