Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 48 additions & 0 deletions .planning/2026-07-17-architecture-deepening/task_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Architecture deepening — three Strong candidates

Branch: `refactor/deepen-modules`. Source: architecture review 2026-07-17
(`%TEMP%\architecture-review-20260716-223837.html`). Vocabulary per
`.agents/skills/codebase-design` (module, interface, depth, seam, adapter,
leverage, locality); domain terms per `CONTEXT.md`.

## Scope (in)

Three Strong candidates, sequential commits, smallest first:

1. **Active-branch query module.** Nine hand-rolled parentId walks across six
files (ChatSessionBootstrap, UsageFooter, SessionCompactionService,
AutoCompactionService, ChatSession, SessionTreeChoices, SessionForkService,
SlashCommandAdvanced import) get one home. Consumers stop walking the tree.
2. **One config mutation path.** Widen `ProviderConfigurator` until every
front-end's mutation fits (set-defaults-with-repair as used by
`providers use`/`models use`; the `/provider` slash repair policy routes
through the same seam). Delete `ConfigFileUpdater` (unvalidated splice).
LoginCommand consolidation is NOT in scope (that's card 6).
3. **One Turn event-consumption module.** Extract the AgentEvent consumption
loop shared by `ExecuteTurnCoreAsync` (text), `RunJsonTurnAsync` (JSON),
and `RpcHost.RunTurnAsync` into one module owning: artifact append, the
Failed → Completed("partial") terminal protocol, usage extraction, and
steering→follow-up promotion. Front-ends become presentation adapters.
Behaviour parity for REPL/JSON; RPC gains the previously-drifted
steering-promotion behaviour only if it fits the RPC contract cleanly —
otherwise parity and a noted follow-up.

## Scope (out)

- Card 4 (slash-command registry), card 5 (consume-vs-cut capabilities),
card 6 (OAuth login orchestration) — Worth-exploring cards, need a design
decision pass first.
- Appendix items except where a card touches them directly.
- No behaviour changes visible to users except drift removal in card 3's
consumers and (if clean) RPC steering promotion.

## Acceptance criteria

- `dotnet build WinHarness.sln -c Release` clean (TreatWarningsAsErrors gate).
- `dotnet test WinHarness.sln -c Release --no-build` green (5 non-Windows
skips expected — this box is Windows, so all should run).
- New modules covered by unit tests through their interfaces (no
InternalsVisibleTo additions, no reflection in tests).
- No public interface changes to IAgentRuntime's method signature; the
terminal-event protocol becomes documented on the extracted module.
- CONTEXT.md updated if new domain terms are named.
110 changes: 110 additions & 0 deletions src/WinHarness.Cli/Chat/ActiveBranch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using WinHarness.Conversation;
using WinHarness.Sessions;
using ConversationState = WinHarness.Conversation.Conversation;

namespace WinHarness.Cli.Chat;

/// <summary>
/// The active branch of a session, loaded once from root to leaf, answering the
/// branch queries the CLI needs: last-entry-of-kind, message counts, usage
/// totals, and flattening to conversation messages.
/// </summary>
internal sealed class ActiveBranch
{
private readonly IReadOnlyList<SessionEntry> _entries;

private ActiveBranch(IReadOnlyList<SessionEntry> entries)
{
_entries = entries;
}

/// <summary>
/// Loads the active branch (root to leaf) from a session manager.
/// </summary>
public static ActiveBranch Load(ISessionManager sessionManager)
{
ArgumentNullException.ThrowIfNull(sessionManager);
return new ActiveBranch(sessionManager.GetActiveBranch());
}

/// <summary>
/// Wraps an already-ordered entry list, e.g. entries from a validated import file.
/// </summary>
public static ActiveBranch FromEntries(IReadOnlyList<SessionEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
return new ActiveBranch(entries);
}

/// <summary>
/// Gets the branch entries from root to leaf.
/// </summary>
public IReadOnlyList<SessionEntry> Entries => _entries;

/// <summary>
/// Finds the most recent branch entry of type <typeparamref name="T"/>, or
/// <see langword="null"/> when no entry matches.
/// </summary>
public T? LastOfType<T>(Func<T, bool>? predicate = null)
where T : SessionEntry
{
for (int index = _entries.Count - 1; index >= 0; index--)
{
if (_entries[index] is T entry && (predicate is null || predicate(entry)))
{
return entry;
}
}

return null;
}

/// <summary>
/// Counts message entries on the branch.
/// </summary>
public int CountMessageEntries() => _entries.Count(static entry => entry is MessageSessionEntry);

/// <summary>
/// Sums assistant-message usage over the branch; unset token counts add zero.
/// </summary>
public (long InputTokens, long OutputTokens) SumAssistantUsage()
{
long input = 0;
long output = 0;
foreach (SessionEntry entry in _entries)
{
if (entry is MessageSessionEntry { Message: { Role: ConversationRole.Assistant, Usage: { } usage } })
{
input += usage.InputTokens ?? 0;
output += usage.OutputTokens ?? 0;
}
}

return (input, output);
}

/// <summary>
/// Flattens the branch into its conversation messages, in branch order.
/// </summary>
public List<ConversationMessage> FlattenMessages() =>
_entries
.OfType<MessageSessionEntry>()
.Select(static entry => entry.Message)
.ToList();

/// <summary>
/// Sums message text length over a built conversation; the input for the
/// chars-per-token estimation heuristic.
/// </summary>
public static long SumMessageTextChars(ConversationState conversation)
{
ArgumentNullException.ThrowIfNull(conversation);
long total = 0;
foreach (ConversationMessage message in conversation.Messages)
{
total += message.Text.Length;
}

return total;
}
}
9 changes: 2 additions & 7 deletions src/WinHarness.Cli/Chat/AutoCompactionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,8 @@ public static int ResolveContextWindow(WinHarnessOptions options, string provide
/// </summary>
public static long EstimateConversationTokens(ChatSession session)
{
long chars = 0;
foreach (Conversation.ConversationMessage message in session.SessionManager
.BuildConversation(session.SelectedSkill?.SystemPrompt).Messages)
{
chars += message.Text.Length;
}

long chars = ActiveBranch.SumMessageTextChars(
session.SessionManager.BuildConversation(session.SelectedSkill?.SystemPrompt));
return chars / CharsPerToken;
}

Expand Down
2 changes: 1 addition & 1 deletion src/WinHarness.Cli/Chat/ChatSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public ChatSession(
public bool IsEphemeral => !SessionManager.IsPersisted;

public int CountActiveBranchMessages() =>
SessionManager.GetActiveBranch().Count(static entry => entry is MessageSessionEntry);
ActiveBranch.Load(SessionManager).CountMessageEntries();

public void ReplaceSessionManager(ISessionManager sessionManager)
{
Expand Down
11 changes: 2 additions & 9 deletions src/WinHarness.Cli/Chat/ChatSessionBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,15 +214,8 @@ private static async ValueTask<ISessionManager> OpenByPathOrIdAsync(

internal static (string? ProviderId, string? ModelId) TryRestoreModelChange(ISessionManager sessionManager)
{
for (int index = sessionManager.GetActiveBranch().Count - 1; index >= 0; index--)
{
if (sessionManager.GetActiveBranch()[index] is ModelChangeSessionEntry modelChange)
{
return (modelChange.ProviderId, modelChange.ModelId);
}
}

return (null, null);
ModelChangeSessionEntry? modelChange = ActiveBranch.Load(sessionManager).LastOfType<ModelChangeSessionEntry>();
return modelChange is null ? (null, null) : (modelChange.ProviderId, modelChange.ModelId);
}
}

Expand Down
46 changes: 10 additions & 36 deletions src/WinHarness.Cli/Chat/SessionCompactionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,16 @@ public async ValueTask<CompactionResult> CompactAsync(
return CompactionResult.Failed("/compact requires a persisted session.");
}

IReadOnlyList<SessionEntry> branch = session.GetActiveBranch();
List<MessageSessionEntry> messageEntries = branch.OfType<MessageSessionEntry>().ToList();
ActiveBranch branch = ActiveBranch.Load(session);
List<MessageSessionEntry> messageEntries = branch.Entries.OfType<MessageSessionEntry>().ToList();
if (messageEntries.Count < CompactionKeepMessageEntries)
{
return CompactionResult.Failed(
$"Nothing to compact — need at least {CompactionKeepMessageEntries} message entries on the active branch.");
}

ConversationState before = session.BuildConversation(skillSystemPrompt);
int charsBefore = CountConversationChars(before);
long charsBefore = ActiveBranch.SumMessageTextChars(before);

string summary = await RunSummarizationAsync(
session,
Expand All @@ -64,13 +64,16 @@ public async ValueTask<CompactionResult> CompactAsync(
cancellationToken).ConfigureAwait(false);

string firstKeptEntryId = messageEntries[^CompactionKeepMessageEntries].Id;
long? tokensBefore = FindLastUsageTokens(branch);
long? tokensBefore = branch
.LastOfType<MessageSessionEntry>(static entry =>
entry.Message.Role == ConversationRole.Assistant && entry.Message.Usage?.TotalTokens is not null)
?.Message.Usage?.TotalTokens;

await session.AppendCompactionAsync(summary, firstKeptEntryId, tokensBefore, cancellationToken)
.ConfigureAwait(false);

ConversationState after = session.BuildConversation(skillSystemPrompt);
int charsAfter = CountConversationChars(after);
long charsAfter = ActiveBranch.SumMessageTextChars(after);

return CompactionResult.Completed(charsBefore, charsAfter);
}
Expand Down Expand Up @@ -125,42 +128,13 @@ private async ValueTask<string> RunSummarizationAsync(

return summary;
}

private static int CountConversationChars(ConversationState conversation)
{
int total = 0;
foreach (ConversationMessage message in conversation.Messages)
{
total += message.Text.Length;
}

return total;
}

private static long? FindLastUsageTokens(IReadOnlyList<SessionEntry> branch)
{
for (int index = branch.Count - 1; index >= 0; index--)
{
if (branch[index] is not MessageSessionEntry { Message.Role: ConversationRole.Assistant } messageEntry)
{
continue;
}

if (messageEntry.Message.Usage?.TotalTokens is long tokens)
{
return tokens;
}
}

return null;
}
}

internal sealed record CompactionResult(bool Succeeded, string? Message, int CharsBefore = 0, int CharsAfter = 0)
internal sealed record CompactionResult(bool Succeeded, string? Message, long CharsBefore = 0, long CharsAfter = 0)
{
public static CompactionResult Failed(string message) => new(false, message);

public static CompactionResult Completed(int charsBefore, int charsAfter) =>
public static CompactionResult Completed(long charsBefore, long charsAfter) =>
new(
true,
$"Compaction complete. Active context reduced from ~{charsBefore:N0} to ~{charsAfter:N0} characters.",
Expand Down
5 changes: 1 addition & 4 deletions src/WinHarness.Cli/Chat/SessionForkService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,7 @@ public async ValueTask<ForkResult> ForkAsync(

ISessionManager forked = await _factory.CreateAsync(cwd, cancellationToken).ConfigureAwait(false);

List<ConversationMessage> messages = source.GetActiveBranch()
.OfType<MessageSessionEntry>()
.Select(static entry => entry.Message)
.ToList();
List<ConversationMessage> messages = ActiveBranch.Load(source).FlattenMessages();

if (messages.Count > 0)
{
Expand Down
2 changes: 1 addition & 1 deletion src/WinHarness.Cli/Chat/SessionTreeChoices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public static IReadOnlyList<string> ApplyBranch(

onBranch(selected.Id);

int messageCount = sessionManager.GetActiveBranch().Count(static entry => entry is MessageSessionEntry);
int messageCount = ActiveBranch.Load(sessionManager).CountMessageEntries();
return
[
$"Branched to entry {selected.Id}.",
Expand Down
5 changes: 1 addition & 4 deletions src/WinHarness.Cli/Chat/SlashCommandAdvanced.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,7 @@ public static async ValueTask<SlashCommandResult> ImportAsync(

ISessionManager imported = await context.SessionFactory
.CreateAsync(session.WorkspaceRoot, context.CancellationToken).ConfigureAwait(false);
List<ConversationMessage> messages = entries
.OfType<MessageSessionEntry>()
.Select(static entry => entry.Message)
.ToList();
List<ConversationMessage> messages = ActiveBranch.FromEntries(entries).FlattenMessages();
if (messages.Count > 0)
{
await imported.AppendMessagesAsync(messages, context.CancellationToken).ConfigureAwait(false);
Expand Down
6 changes: 2 additions & 4 deletions src/WinHarness.Cli/Chat/SlashCommandProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Linq;
using Spectre.Console;
using WinHarness.Configuration;
using WinHarness.Infrastructure.Configuration;
using WinHarness.Sessions;

namespace WinHarness.Cli.Chat;
Expand Down Expand Up @@ -860,10 +861,7 @@ private static async ValueTask<SlashCommandResult> SwitchProviderAsync(
}

session.ProviderId = provider.Id;
if (!provider.Models.Any(model => string.Equals(model.Id, session.ModelId, StringComparison.OrdinalIgnoreCase)))
{
session.ModelId = provider.Models.Count > 0 ? provider.Models[0].Id : string.Empty;
}
session.ModelId = ProviderConfigurator.RepairDefaultModel(options, provider.Id, session.ModelId);

await AppendModelChangeIfPersistedAsync(session, context).ConfigureAwait(false);
return SlashCommandResult.Handled([$"Provider {session.ProviderId}, model {session.ModelId}."]);
Expand Down
Loading
Loading