refactor: Fluxor 1/N — foundation & the settings slice #27

Merged
bjoern merged 2 commits from refactor/fluxor-settings into main 2026-08-13 06:23:57 +02:00
Member

First slice of the Fluxor adoption (ADR 0011, now implemented; the sequencing agreed with bjoern: settings first, project pages last — another agent is working there).

Foundation (ported from Kagura's proven shape)

  • Fluxor.Blazor.Web at Kagura's 6.10.0 pin; the host scans the adapter assembly for features.
  • Error containment ported whole, not log-only: OrihonStoreInitializer wraps Fluxor's initializer and claims unhandled effect exceptions — e.Handled() is load-bearing (Kagura issue #182: without it, one throwing effect kills the circuit with the generic error bar). The per-circuit CircuitErrorSink logs once per exception instance (the callback is multicast across every mounted initializer), the first initializer claims presentation so duplicate modals never stack, and the modal (CircuitErrorPanel: headline + disclosure with the full stack) offers Continue / Reload instead of a dead circuit.
  • CircuitErrorContainmentTests: a deliberately detonating effect scanned from the test assembly throws mid-dispatch → the contained modal appears with the message, Continue dismisses it, and the page underneath still works. The circuit-survives contract is pinned, not assumed.

The settings slice

  • SettingsState[FeatureState] record: settings DTO, catalog (null while unavailable), key-save state, per-agent errors as an ImmutableDictionary. Projections (OptionsFor/SelectedFor, default pseudo-option, delisted stand-in) stay pure functions over the state.
  • Actions split request/outcome; static reducers; SettingsEffects is now the only place the page's use cases are touched. One deliberate improvement over the imperative version: the catalog load chains off every SettingsLoaded (key present → fetch; absent → the explained-empty state), so a successful key save refreshes HasKey and the pickers through one cascade — the old page hand-sequenced those calls.
  • SettingsPage becomes a FluxorComponent: renders the store, dispatches, no local state left.

Verification

  • All 13 existing SettingsPageTests pass unchanged — the behavioral contract (validated save, vision filter, default-clearing pick, error scoping, deep links) held through the refactor; the adapter context now builds the same store the host does (scanning the test assembly too, for the detonator).
  • 357 total, all green.
  • Browser-verified live: deep link ?tab=agents renders all 7 rows with the no-key warning from the effect cascade; a bogus key dispatches through the store → real OpenRouter 401 → "OpenRouter rejected the API key." renders from the reducer.

Next slices: run monitor (+ a coalescing RunChangedBridge on Kagura's DomainChangedBridge pattern), bible page, page workspace, wizard, then project list/workspace after the other agent's work lands. Cut 7's chat then starts on Fluxor.

🤖 Generated with Claude Code

First slice of the Fluxor adoption (ADR 0011, now implemented; the sequencing agreed with bjoern: settings first, project pages last — another agent is working there). ## Foundation (ported from Kagura's proven shape) - `Fluxor.Blazor.Web` at Kagura's 6.10.0 pin; the host scans the adapter assembly for features. - **Error containment ported whole**, not log-only: `OrihonStoreInitializer` wraps Fluxor's initializer and claims unhandled effect exceptions — `e.Handled()` is load-bearing (Kagura issue #182: without it, one throwing effect kills the circuit with the generic error bar). The per-circuit `CircuitErrorSink` logs once per exception instance (the callback is multicast across every mounted initializer), the **first** initializer claims presentation so duplicate modals never stack, and the modal (`CircuitErrorPanel`: headline + disclosure with the full stack) offers *Continue* / *Reload* instead of a dead circuit. - `CircuitErrorContainmentTests`: a **deliberately detonating effect scanned from the test assembly** throws mid-dispatch → the contained modal appears with the message, *Continue* dismisses it, and the page underneath still works. The circuit-survives contract is pinned, not assumed. ## The settings slice - `SettingsState` — `[FeatureState]` record: settings DTO, catalog (null while unavailable), key-save state, per-agent errors as an `ImmutableDictionary`. Projections (`OptionsFor`/`SelectedFor`, default pseudo-option, delisted stand-in) stay pure functions over the state. - Actions split request/outcome; static reducers; **`SettingsEffects` is now the only place the page's use cases are touched**. One deliberate improvement over the imperative version: the catalog load *chains off every `SettingsLoaded`* (key present → fetch; absent → the explained-empty state), so a successful key save refreshes `HasKey` and the pickers through one cascade — the old page hand-sequenced those calls. - `SettingsPage` becomes a `FluxorComponent`: renders the store, dispatches, no local state left. ## Verification - **All 13 existing `SettingsPageTests` pass unchanged** — the behavioral contract (validated save, vision filter, default-clearing pick, error scoping, deep links) held through the refactor; the adapter context now builds the same store the host does (scanning the test assembly too, for the detonator). - 357 total, all green. - Browser-verified live: deep link `?tab=agents` renders all 7 rows with the no-key warning from the effect cascade; a bogus key dispatches through the store → real OpenRouter 401 → "OpenRouter rejected the API key." renders from the reducer. Next slices: run monitor (+ a coalescing `RunChangedBridge` on Kagura's `DomainChangedBridge` pattern), bible page, page workspace, wizard, then project list/workspace after the other agent's work lands. Cut 7's chat then starts on Fluxor. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
refactor: adopt Fluxor — the foundation and the settings slice
All checks were successful
CI / build (pull_request) Successful in 25s
CI / test (pull_request) Successful in 32s
bc1125a0ef
ADR 0011's implementation lands, mirroring Kagura's proven shape. The
foundation: the Fluxor package at Kagura's pin, the host scanning the
adapter for features, and the hard-won error containment ported whole —
OrihonStoreInitializer claims unhandled effect exceptions (Handled() is
load-bearing; without it one throwing effect kills the circuit), the
per-circuit CircuitErrorSink logs once and presents once, and the
contained modal offers Continue instead of the dead error bar. A
test-assembly effect detonates on purpose to prove the circuit
survives.

Settings is the first refactored slice: a [FeatureState] record with
pure projections, request/outcome actions, static reducers, and an
effects class that is now the only place the page's use cases are
touched — the catalog load chains off every settings load, so a key
save refreshes the pickers through one cascade instead of imperative
call sequences. The page becomes a FluxorComponent that renders the
store and dispatches; all 13 of its behavioral tests pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary

Summary
Generated on: 07/25/2026 - 09:08:05
Coverage date: 07/25/2026 - 09:07:57 - 07/25/2026 - 09:08:03
Parser: MultiReport (4x Cobertura)
Assemblies: 6
Classes: 197
Files: 131
Line coverage: 93.3% (6174 of 6612)
Covered lines: 6174
Uncovered lines: 438
Coverable lines: 6612
Total lines: 11520
Branch coverage: 76.6% (1226 of 1600)
Covered branches: 1226
Total branches: 1600
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 84.2%
Name Line Branch
Orihon.BlazorAdapter 84.2% 79%
Orihon.BlazorAdapter.Bible.BiblePage 71.7% 59%
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.BlazorAdapter.Debounce 91.6% 93.7%
Orihon.BlazorAdapter.Diagnostics.CircuitError 100%
Orihon.BlazorAdapter.Diagnostics.CircuitErrorPanel 100%
Orihon.BlazorAdapter.Diagnostics.CircuitErrorSink 100% 85.7%
Orihon.BlazorAdapter.Diagnostics.OrihonStoreInitializer 85.7% 66.6%
Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage 84.6% 77.8%
Orihon.BlazorAdapter.Projects.ProjectListPage 86% 85.2%
Orihon.BlazorAdapter.Projects.ProjectWizardPage 91.3% 78.2%
Orihon.BlazorAdapter.Runs.RunMonitor 95.3% 97.7%
Orihon.BlazorAdapter.Settings.AgentModelPicked 100%
Orihon.BlazorAdapter.Settings.AgentModelSaved 100%
Orihon.BlazorAdapter.Settings.AgentModelSaveFailed 100%
Orihon.BlazorAdapter.Settings.KeySaved 100%
Orihon.BlazorAdapter.Settings.KeySaveFailed 100%
Orihon.BlazorAdapter.Settings.ModelOptionsLoaded 100%
Orihon.BlazorAdapter.Settings.ModelOptionsUnavailable 100%
Orihon.BlazorAdapter.Settings.SaveKeyRequested 100%
Orihon.BlazorAdapter.Settings.SettingsEffects 100% 100%
Orihon.BlazorAdapter.Settings.SettingsLoaded 100%
Orihon.BlazorAdapter.Settings.SettingsPage 100% 90.4%
Orihon.BlazorAdapter.Settings.SettingsReducers 100%
Orihon.BlazorAdapter.Settings.SettingsState 100%
Orihon.BlazorAdapter.Uploads.UploadTransfer 96.5% 100%
Orihon.BlazorAdapter.Uploads.UploadTransferProgress 100% 100%
Orihon.BlazorAdapter.Uploads.UploadTransferResult 100%
Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage 65.4% 73.3%
Orihon.Domain - 100%
Name Line Branch
Orihon.Domain 100% 100%
Orihon.Domain.Agents.AgentDescriptor 100%
Orihon.Domain.Agents.AgentRoster 100% 100%
Orihon.Domain.Bible.Character 100% 100%
Orihon.Domain.Bible.GlossaryEntry 100% 100%
Orihon.Domain.Bible.LoreEntry 100% 100%
Orihon.Domain.Bible.PageSummary 100%
Orihon.Domain.Bible.StoryBeat 100%
Orihon.Domain.Projects.Project 100% 100%
Orihon.Domain.Projects.ProjectProfile 100%
Orihon.Domain.Runs.Execution 100% 100%
Orihon.Domain.Runs.Run 100%
Orihon.Domain.Settings.AppSetting 100%
Orihon.Domain.Text 100% 100%
Orihon.Domain.Translation.BoundingBox 100%
Orihon.Domain.Translation.Chapter 100%
Orihon.Domain.Translation.Page 100%
Orihon.Domain.Translation.Region 100% 100%
Orihon.Domain.Translation.RegionProfile 100%
Orihon.Infrastructure - 93.7%
Name Line Branch
Orihon.Infrastructure 93.7% 65.7%
Orihon.Infrastructure.Bible.EfBibleStore 100% 100%
Orihon.Infrastructure.DependencyInjection 100%
Orihon.Infrastructure.Gateways.AgentToolAdapter 100%
Orihon.Infrastructure.Gateways.AgentToolAdapter`1 100% 100%
Orihon.Infrastructure.Gateways.HttpWebPageFetcher 95.1% 83.3%
Orihon.Infrastructure.Gateways.OpenRouterLlmGateway 98.9% 84.1%
Orihon.Infrastructure.Persistence.Configurations.AppSettingConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ExecutionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper 100%
Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RunConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration 100%
Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Orihon.Infrastructure.Persistence.Migrations.AddAppSettings 99.3%
Orihon.Infrastructure.Persistence.Migrations.AddRuns 99.1%
Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain 97.3%
Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot 100%
Orihon.Infrastructure.Persistence.OrihonDbContext 100%
Orihon.Infrastructure.Persistence.OrihonDbContextFactory 100%
Orihon.Infrastructure.Projects.EfProjectStore 100% 100%
Orihon.Infrastructure.Projects.FileSystemPageImageStore 100% 100%
Orihon.Infrastructure.Runs.EfRunStore 97% 50%
Orihon.Infrastructure.Settings.EfAppSettingsStore 100% 100%
Orihon.Infrastructure.Translation.EfChapterStore 100% 100%
Orihon.Infrastructure.Translation.EfPageStore 100% 100%
Orihon.Infrastructure.Translation.EfRegionStore 100% 100%
Orihon.Infrastructure.Translation.Ordering 100% 100%
System.Text.RegularExpressions.Generated 70.6% 53.3%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4
77.9% 76.6%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1
59% 42.5%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3
89.4% 75%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2
83.7% 62.5%
Orihon.Kernel - 90.9%
Name Line Branch
Orihon.Kernel 90.9% 75%
Orihon.Kernel.Err`1 100%
Orihon.Kernel.Ok`1 100%
Orihon.Kernel.Result`1 88.8% 75%
Orihon.Server - 93.4%
Name Line Branch
Orihon.Server 93.4% 68.4%
Orihon.Server.Components.App 100%
Orihon.Server.Components.Layout.MainLayout 100%
Orihon.Server.Components.Pages.Gate 64.2% 66.6%
Orihon.Server.RunEngineBootstrap 100%
Orihon.Server.Security.AccessGate 91.8% 41.6%
Orihon.Server.Security.AccessSecret 100% 50%
Orihon.Server.VolumeStartupValidator 100% 100%
Program 95.4% 85.7%
Orihon.UseCases - 96.6%
Name Line Branch
Orihon.UseCases 96.6% 89.8%
Orihon.UseCases.Agents.AgentBlueprint 100%
Orihon.UseCases.Agents.AgentInvocation 100%
Orihon.UseCases.Agents.AgentOutcome 100%
Orihon.UseCases.Agents.AgentTool`1 90.9% 75%
Orihon.UseCases.Agents.AgentToolImage 100%
Orihon.UseCases.Agents.AgentToolResult 100%
Orihon.UseCases.Agents.AssistantSpoke 100%
Orihon.UseCases.Agents.ResearchSetup.AddGlossaryParams 100%
Orihon.UseCases.Agents.ResearchSetup.AddGlossaryTool 100% 100%
Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatParams 100%
Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatTool 100% 50%
Orihon.UseCases.Agents.ResearchSetup.AskUserParams 100%
Orihon.UseCases.Agents.ResearchSetup.AskUserTool 100% 100%
Orihon.UseCases.Agents.ResearchSetup.FetchUrlParams 100%
Orihon.UseCases.Agents.ResearchSetup.FetchUrlTool 100% 100%
Orihon.UseCases.Agents.ResearchSetup.ListBibleTool 86.6%
Orihon.UseCases.Agents.ResearchSetup.PageByNumber 90% 87.5%
Orihon.UseCases.Agents.ResearchSetup.ResearchSetupBlueprint 100%
Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryParams 100%
Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryTool 100% 75%
Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataParams 100%
Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataTool 96.1% 90.9%
Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterParams 100%
Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterTool 91.3% 66.6%
Orihon.UseCases.Agents.ResearchSetup.UpsertLoreParams 100%
Orihon.UseCases.Agents.ResearchSetup.UpsertLoreTool 91.3% 66.6%
Orihon.UseCases.Agents.ResearchSetup.ViewPageParams 100%
Orihon.UseCases.Agents.ResearchSetup.ViewPageTool 85.7% 50%
Orihon.UseCases.Agents.ToolCalled 100%
Orihon.UseCases.Agents.ToolCompleted 100%
Orihon.UseCases.Bible.AddCharacter 100% 100%
Orihon.UseCases.Bible.AddGlossaryEntry 100% 100%
Orihon.UseCases.Bible.AddLoreEntry 100% 100%
Orihon.UseCases.Bible.AddStoryBeat 100% 100%
Orihon.UseCases.Bible.BibleDto 100%
Orihon.UseCases.Bible.CharacterDto 100%
Orihon.UseCases.Bible.DeleteCharacter 100% 100%
Orihon.UseCases.Bible.DeleteGlossaryEntry 100% 100%
Orihon.UseCases.Bible.DeleteLoreEntry 100% 100%
Orihon.UseCases.Bible.DeletePageSummary 100% 100%
Orihon.UseCases.Bible.DeleteStoryBeat 100% 100%
Orihon.UseCases.Bible.GetBible 100% 100%
Orihon.UseCases.Bible.GlossaryEntryDto 100%
Orihon.UseCases.Bible.LoreEntryDto 100%
Orihon.UseCases.Bible.PageSummaryDto 100%
Orihon.UseCases.Bible.ReorderStoryBeats 100%
Orihon.UseCases.Bible.SetPageSummary 100% 100%
Orihon.UseCases.Bible.StoryBeatDto 100%
Orihon.UseCases.Bible.UpdateCharacter 100% 100%
Orihon.UseCases.Bible.UpdateGlossaryEntry 100% 100%
Orihon.UseCases.Bible.UpdateLoreEntry 100% 100%
Orihon.UseCases.Bible.UpdateStoryBeat 100% 100%
Orihon.UseCases.Chapters.ChapterDto 100%
Orihon.UseCases.Chapters.CreateChapter 100% 100%
Orihon.UseCases.Chapters.DeleteChapter 100% 100%
Orihon.UseCases.Chapters.RenameChapter 100% 100%
Orihon.UseCases.Chapters.ReorderChapters 100%
Orihon.UseCases.DependencyInjection 100%
Orihon.UseCases.Diagnostics.SeedDevData 99.2% 92.8%
Orihon.UseCases.Gateways.LlmKeyInfo 100%
Orihon.UseCases.Gateways.LlmModel 100%
Orihon.UseCases.NextOrder 100%
Orihon.UseCases.Pages.DeletePage 100% 100%
Orihon.UseCases.Pages.GetPage 100% 100%
Orihon.UseCases.Pages.GetProjectWorkspace 100% 100%
Orihon.UseCases.Pages.ImportPages 97.7% 96.6%
Orihon.UseCases.Pages.ImportPagesResult 100%
Orihon.UseCases.Pages.MovePage 100% 92.8%
Orihon.UseCases.Pages.PageDetailDto 100%
Orihon.UseCases.Pages.PageDto 100%
Orihon.UseCases.Pages.PageUpload 100%
Orihon.UseCases.Pages.ProjectWorkspaceDto 100%
Orihon.UseCases.Pages.ReorderPages 100%
Orihon.UseCases.Pages.SetPageMeta 100% 100%
Orihon.UseCases.Pages.WorkspaceChapterDto 100%
Orihon.UseCases.Projects.CompleteProjectSetup 100% 93.7%
Orihon.UseCases.Projects.CreateProject 100% 100%
Orihon.UseCases.Projects.DeleteProject 100% 100%
Orihon.UseCases.Projects.GetProject 100% 100%
Orihon.UseCases.Projects.ListProjects 100%
Orihon.UseCases.Projects.ProjectDto 95.8%
Orihon.UseCases.Projects.StoredPageImage 100%
Orihon.UseCases.Projects.UpdateProjectMetadata 100% 100%
Orihon.UseCases.Regions.CreateRegion 100% 100%
Orihon.UseCases.Regions.DeleteRegion 100% 100%
Orihon.UseCases.Regions.RegionDto 97%
Orihon.UseCases.Regions.ReorderRegions 100%
Orihon.UseCases.Regions.UpdateRegion 100% 100%
Orihon.UseCases.Runs.ExecutionDto 92.3%
Orihon.UseCases.Runs.PlannedExecution 100%
Orihon.UseCases.Runs.RunDto 93.3% 90%
Orihon.UseCases.Runs.RunEngine 88.3% 82%
Orihon.UseCases.Runs.RunEngineOptions 100%
Orihon.UseCases.Runs.StageContext 37.5%
Orihon.UseCases.Settings.AgentSettingDto 100% 100%
Orihon.UseCases.Settings.GetSettings 100% 100%
Orihon.UseCases.Settings.ListModelOptions 100% 100%
Orihon.UseCases.Settings.SaveAgentModel 100% 100%
Orihon.UseCases.Settings.SaveOpenRouterKey 100% 100%
Orihon.UseCases.Settings.SettingKeys 100% 100%
Orihon.UseCases.Settings.SettingsDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/25/2026 - 09:08:05 | | Coverage date: | 07/25/2026 - 09:07:57 - 07/25/2026 - 09:08:03 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 6 | | Classes: | 197 | | Files: | 131 | | **Line coverage:** | 93.3% (6174 of 6612) | | Covered lines: | 6174 | | Uncovered lines: | 438 | | Coverable lines: | 6612 | | Total lines: | 11520 | | **Branch coverage:** | 76.6% (1226 of 1600) | | Covered branches: | 1226 | | Total branches: | 1600 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 84.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**84.2%**|**79%**| |Orihon.BlazorAdapter.Bible.BiblePage|71.7%|59%| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| |Orihon.BlazorAdapter.Debounce|91.6%|93.7%| |Orihon.BlazorAdapter.Diagnostics.CircuitError|100%|| |Orihon.BlazorAdapter.Diagnostics.CircuitErrorPanel|100%|| |Orihon.BlazorAdapter.Diagnostics.CircuitErrorSink|100%|85.7%| |Orihon.BlazorAdapter.Diagnostics.OrihonStoreInitializer|85.7%|66.6%| |Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage|84.6%|77.8%| |Orihon.BlazorAdapter.Projects.ProjectListPage|86%|85.2%| |Orihon.BlazorAdapter.Projects.ProjectWizardPage|91.3%|78.2%| |Orihon.BlazorAdapter.Runs.RunMonitor|95.3%|97.7%| |Orihon.BlazorAdapter.Settings.AgentModelPicked|100%|| |Orihon.BlazorAdapter.Settings.AgentModelSaved|100%|| |Orihon.BlazorAdapter.Settings.AgentModelSaveFailed|100%|| |Orihon.BlazorAdapter.Settings.KeySaved|100%|| |Orihon.BlazorAdapter.Settings.KeySaveFailed|100%|| |Orihon.BlazorAdapter.Settings.ModelOptionsLoaded|100%|| |Orihon.BlazorAdapter.Settings.ModelOptionsUnavailable|100%|| |Orihon.BlazorAdapter.Settings.SaveKeyRequested|100%|| |Orihon.BlazorAdapter.Settings.SettingsEffects|100%|100%| |Orihon.BlazorAdapter.Settings.SettingsLoaded|100%|| |Orihon.BlazorAdapter.Settings.SettingsPage|100%|90.4%| |Orihon.BlazorAdapter.Settings.SettingsReducers|100%|| |Orihon.BlazorAdapter.Settings.SettingsState|100%|| |Orihon.BlazorAdapter.Uploads.UploadTransfer|96.5%|100%| |Orihon.BlazorAdapter.Uploads.UploadTransferProgress|100%|100%| |Orihon.BlazorAdapter.Uploads.UploadTransferResult|100%|| |Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage|65.4%|73.3%| </details> <details><summary>Orihon.Domain - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Domain**|**100%**|**100%**| |Orihon.Domain.Agents.AgentDescriptor|100%|| |Orihon.Domain.Agents.AgentRoster|100%|100%| |Orihon.Domain.Bible.Character|100%|100%| |Orihon.Domain.Bible.GlossaryEntry|100%|100%| |Orihon.Domain.Bible.LoreEntry|100%|100%| |Orihon.Domain.Bible.PageSummary|100%|| |Orihon.Domain.Bible.StoryBeat|100%|| |Orihon.Domain.Projects.Project|100%|100%| |Orihon.Domain.Projects.ProjectProfile|100%|| |Orihon.Domain.Runs.Execution|100%|100%| |Orihon.Domain.Runs.Run|100%|| |Orihon.Domain.Settings.AppSetting|100%|| |Orihon.Domain.Text|100%|100%| |Orihon.Domain.Translation.BoundingBox|100%|| |Orihon.Domain.Translation.Chapter|100%|| |Orihon.Domain.Translation.Page|100%|| |Orihon.Domain.Translation.Region|100%|100%| |Orihon.Domain.Translation.RegionProfile|100%|| </details> <details><summary>Orihon.Infrastructure - 93.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Infrastructure**|**93.7%**|**65.7%**| |Orihon.Infrastructure.Bible.EfBibleStore|100%|100%| |Orihon.Infrastructure.DependencyInjection|100%|| |Orihon.Infrastructure.Gateways.AgentToolAdapter|100%|| |Orihon.Infrastructure.Gateways.AgentToolAdapter`1|100%|100%| |Orihon.Infrastructure.Gateways.HttpWebPageFetcher|95.1%|83.3%| |Orihon.Infrastructure.Gateways.OpenRouterLlmGateway|98.9%|84.1%| |Orihon.Infrastructure.Persistence.Configurations.AppSettingConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ExecutionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper|100%|| |Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RunConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration|100%|| |Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Orihon.Infrastructure.Persistence.Migrations.AddAppSettings|99.3%|| |Orihon.Infrastructure.Persistence.Migrations.AddRuns|99.1%|| |Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain|97.3%|| |Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContext|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContextFactory|100%|| |Orihon.Infrastructure.Projects.EfProjectStore|100%|100%| |Orihon.Infrastructure.Projects.FileSystemPageImageStore|100%|100%| |Orihon.Infrastructure.Runs.EfRunStore|97%|50%| |Orihon.Infrastructure.Settings.EfAppSettingsStore|100%|100%| |Orihon.Infrastructure.Translation.EfChapterStore|100%|100%| |Orihon.Infrastructure.Translation.EfPageStore|100%|100%| |Orihon.Infrastructure.Translation.EfRegionStore|100%|100%| |Orihon.Infrastructure.Translation.Ordering|100%|100%| |System.Text.RegularExpressions.Generated|70.6%|53.3%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4|77.9%|76.6%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1|59%|42.5%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3|89.4%|75%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2|83.7%|62.5%| </details> <details><summary>Orihon.Kernel - 90.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Kernel**|**90.9%**|**75%**| |Orihon.Kernel.Err`1|100%|| |Orihon.Kernel.Ok`1|100%|| |Orihon.Kernel.Result`1|88.8%|75%| </details> <details><summary>Orihon.Server - 93.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**93.4%**|**68.4%**| |Orihon.Server.Components.App|100%|| |Orihon.Server.Components.Layout.MainLayout|100%|| |Orihon.Server.Components.Pages.Gate|64.2%|66.6%| |Orihon.Server.RunEngineBootstrap|100%|| |Orihon.Server.Security.AccessGate|91.8%|41.6%| |Orihon.Server.Security.AccessSecret|100%|50%| |Orihon.Server.VolumeStartupValidator|100%|100%| |Program|95.4%|85.7%| </details> <details><summary>Orihon.UseCases - 96.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**96.6%**|**89.8%**| |Orihon.UseCases.Agents.AgentBlueprint|100%|| |Orihon.UseCases.Agents.AgentInvocation|100%|| |Orihon.UseCases.Agents.AgentOutcome|100%|| |Orihon.UseCases.Agents.AgentTool`1|90.9%|75%| |Orihon.UseCases.Agents.AgentToolImage|100%|| |Orihon.UseCases.Agents.AgentToolResult|100%|| |Orihon.UseCases.Agents.AssistantSpoke|100%|| |Orihon.UseCases.Agents.ResearchSetup.AddGlossaryParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.AddGlossaryTool|100%|100%| |Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatTool|100%|50%| |Orihon.UseCases.Agents.ResearchSetup.AskUserParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.AskUserTool|100%|100%| |Orihon.UseCases.Agents.ResearchSetup.FetchUrlParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.FetchUrlTool|100%|100%| |Orihon.UseCases.Agents.ResearchSetup.ListBibleTool|86.6%|| |Orihon.UseCases.Agents.ResearchSetup.PageByNumber|90%|87.5%| |Orihon.UseCases.Agents.ResearchSetup.ResearchSetupBlueprint|100%|| |Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryTool|100%|75%| |Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataTool|96.1%|90.9%| |Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterTool|91.3%|66.6%| |Orihon.UseCases.Agents.ResearchSetup.UpsertLoreParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.UpsertLoreTool|91.3%|66.6%| |Orihon.UseCases.Agents.ResearchSetup.ViewPageParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.ViewPageTool|85.7%|50%| |Orihon.UseCases.Agents.ToolCalled|100%|| |Orihon.UseCases.Agents.ToolCompleted|100%|| |Orihon.UseCases.Bible.AddCharacter|100%|100%| |Orihon.UseCases.Bible.AddGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.AddLoreEntry|100%|100%| |Orihon.UseCases.Bible.AddStoryBeat|100%|100%| |Orihon.UseCases.Bible.BibleDto|100%|| |Orihon.UseCases.Bible.CharacterDto|100%|| |Orihon.UseCases.Bible.DeleteCharacter|100%|100%| |Orihon.UseCases.Bible.DeleteGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.DeleteLoreEntry|100%|100%| |Orihon.UseCases.Bible.DeletePageSummary|100%|100%| |Orihon.UseCases.Bible.DeleteStoryBeat|100%|100%| |Orihon.UseCases.Bible.GetBible|100%|100%| |Orihon.UseCases.Bible.GlossaryEntryDto|100%|| |Orihon.UseCases.Bible.LoreEntryDto|100%|| |Orihon.UseCases.Bible.PageSummaryDto|100%|| |Orihon.UseCases.Bible.ReorderStoryBeats|100%|| |Orihon.UseCases.Bible.SetPageSummary|100%|100%| |Orihon.UseCases.Bible.StoryBeatDto|100%|| |Orihon.UseCases.Bible.UpdateCharacter|100%|100%| |Orihon.UseCases.Bible.UpdateGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.UpdateLoreEntry|100%|100%| |Orihon.UseCases.Bible.UpdateStoryBeat|100%|100%| |Orihon.UseCases.Chapters.ChapterDto|100%|| |Orihon.UseCases.Chapters.CreateChapter|100%|100%| |Orihon.UseCases.Chapters.DeleteChapter|100%|100%| |Orihon.UseCases.Chapters.RenameChapter|100%|100%| |Orihon.UseCases.Chapters.ReorderChapters|100%|| |Orihon.UseCases.DependencyInjection|100%|| |Orihon.UseCases.Diagnostics.SeedDevData|99.2%|92.8%| |Orihon.UseCases.Gateways.LlmKeyInfo|100%|| |Orihon.UseCases.Gateways.LlmModel|100%|| |Orihon.UseCases.NextOrder|100%|| |Orihon.UseCases.Pages.DeletePage|100%|100%| |Orihon.UseCases.Pages.GetPage|100%|100%| |Orihon.UseCases.Pages.GetProjectWorkspace|100%|100%| |Orihon.UseCases.Pages.ImportPages|97.7%|96.6%| |Orihon.UseCases.Pages.ImportPagesResult|100%|| |Orihon.UseCases.Pages.MovePage|100%|92.8%| |Orihon.UseCases.Pages.PageDetailDto|100%|| |Orihon.UseCases.Pages.PageDto|100%|| |Orihon.UseCases.Pages.PageUpload|100%|| |Orihon.UseCases.Pages.ProjectWorkspaceDto|100%|| |Orihon.UseCases.Pages.ReorderPages|100%|| |Orihon.UseCases.Pages.SetPageMeta|100%|100%| |Orihon.UseCases.Pages.WorkspaceChapterDto|100%|| |Orihon.UseCases.Projects.CompleteProjectSetup|100%|93.7%| |Orihon.UseCases.Projects.CreateProject|100%|100%| |Orihon.UseCases.Projects.DeleteProject|100%|100%| |Orihon.UseCases.Projects.GetProject|100%|100%| |Orihon.UseCases.Projects.ListProjects|100%|| |Orihon.UseCases.Projects.ProjectDto|95.8%|| |Orihon.UseCases.Projects.StoredPageImage|100%|| |Orihon.UseCases.Projects.UpdateProjectMetadata|100%|100%| |Orihon.UseCases.Regions.CreateRegion|100%|100%| |Orihon.UseCases.Regions.DeleteRegion|100%|100%| |Orihon.UseCases.Regions.RegionDto|97%|| |Orihon.UseCases.Regions.ReorderRegions|100%|| |Orihon.UseCases.Regions.UpdateRegion|100%|100%| |Orihon.UseCases.Runs.ExecutionDto|92.3%|| |Orihon.UseCases.Runs.PlannedExecution|100%|| |Orihon.UseCases.Runs.RunDto|93.3%|90%| |Orihon.UseCases.Runs.RunEngine|88.3%|82%| |Orihon.UseCases.Runs.RunEngineOptions|100%|| |Orihon.UseCases.Runs.StageContext|37.5%|| |Orihon.UseCases.Settings.AgentSettingDto|100%|100%| |Orihon.UseCases.Settings.GetSettings|100%|100%| |Orihon.UseCases.Settings.ListModelOptions|100%|100%| |Orihon.UseCases.Settings.SaveAgentModel|100%|100%| |Orihon.UseCases.Settings.SaveOpenRouterKey|100%|100%| |Orihon.UseCases.Settings.SettingKeys|100%|100%| |Orihon.UseCases.Settings.SettingsDto|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my, a Fluxor migration! The knowledge-hoarding Flügel in me is tingling~ ♡ State management refactors are where architectures live or die, and this one is mostly a joy to read — the action/effect/reducer split is textbook, the cascade idea is clever, and porting Kagura's hard-won error containment whole (not log-only) shows exactly the right instinct. The 13 existing SettingsPageTests holding green through the refactor is genuinely impressive.

But fufu~... you wouldn't leave a few of these in production, would you? ♡

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. src/Orihon.BlazorAdapter/Settings/SettingsState.cs:18 & SettingsReducers.cs:10 — the Loaded flag is dead state.
    The reducer sets Loaded = true on OnSettingsLoaded, the XML doc promises it "distinguishes 'empty' from 'not yet'" — and then nothing reads it. Not the page (SettingsPage.razor gates every render on State.Value.Settings? nullability, never on Loaded), not the projections, not a single test. I grepped \.Loaded\b across src/ and tests/ — zero consumers. A field whose only writer is a reducer and whose only reader is the doc comment is dead code wearing a contract's clothes. Either wire it (e.g., a "Loading settings…" placeholder keyed off !Loaded) or delete it. A [FeatureState] record is supposed to be the honest source of truth — don't let it lie~ ♡

  2. src/Orihon.BlazorAdapter/Settings/SettingsEffects.cs:75 — the post-pick cascade re-fetches the OpenRouter catalog every time a model is selected.
    OnAgentModelPickedAsync dispatches LoadSettings after a successful save → OnSettingsLoadedAsync sees HasOpenRouterKey == true → dispatches LoadModelOptionsOnLoadModelOptionsAsync calls listModelOptions.ExecuteAsync()a real gateway.ListModelsAsync HTTP round-trip to OpenRouter. The catalog cannot have changed (the key is the same key, the roster is static), so this is a network call for nothing. The imperative version you replaced did NOT do this — ChooseModelAsync called ReloadSettingsAsync() only. This is a behavioral regression: N picks = N redundant catalog fetches. The fake (FakeLlmGateway.ListModelsAsync just returns ModelsResult and records the key) papers over it, so Picking_a_model_saves_it_for_exactly_that_agent passes green either way — which is exactly why it slipped through.
    Fix: either don't re-dispatch LoadSettings from OnAgentModelPickedAsync (the AgentModelSaved reducer can refresh just the settings DTO without chaining the catalog — or the row's ChosenModel can come from the dispatch payload), or break the cascade so LoadModelOptions only fires on the initial load and after a key save, not after every settings reload. The PR body calls the universal cascade "one deliberate improvement" — but the old code's hand-sequencing was correct here: catalog after key-save, settings-only after pick.

  3. tests/Orihon.BlazorAdapter.Tests/CircuitErrorContainmentTests.cs — the headline architectural contract has zero coverage.
    This is the one that makes me sharpen the scythe~ ♡ The entire sophistication of CircuitErrorSinkTryClaimPresenter/ReleasePresenter (presenter election), the ReferenceEquals multicast dedup, the "first initializer claims, duplicates never stack modals" invariant — is completely unexercised. The single test renders one SettingsPage with one OrihonStoreInitializer. It proves the detonator→modal→Continue path (good!) but none of the multi-mount behavior that the complexity exists to handle. The PR body explicitly claims:

    "the first initializer claims presentation so duplicate modals never stack"

    That claim is unverified. I need at least:

    • A test with two OrihonStoreInitializer mounts (the PR body says "several coexist across layout islands" — so simulate it) that detonates one effect and asserts exactly one modal renders (not two, not N).
    • A test asserting the ReferenceEquals dedup: same exception instance reported by N initializers → one Report body execution (one log line, as the comment promises), not N.

    "fufu~ you added three synchronization primitives and a presenter-election protocol but only tested the single-instance happy path? I can't let that slide~ ♡" Untested concurrency/coordination code in a circuit-shared sink is exactly the class of bug that's invisible until production.

💡 Little ideas (non-blocking)~

  1. OrihonStoreInitializer.razor:22Href="." semantics. The "Reload the app" button renders as <a href=".">. With Blazor's blazor.web.js enhanced navigation on, this is intercepted as a client-side nav, and . resolves relative to the current document URL — so from /settings it goes to / (projects list), not a hard reload of settings. That's probably fine (fresh page, circuit continues), but the label says "Reload the app" while the behavior is "navigate to projects home." If a true reload is intended, consider NavigationManager.NavigateTo(uri, forceLoad: true) via @onclick instead of Href. If "go home, fresh state" is the intent, the label could say so. Minor — the circuit survives either way and "Continue" is the primary path.

  2. CircuitErrorSink.cs:33 — stale error resurfaces on re-navigation. If the user picks "Reload the app" (navigates away) rather than "Continue", Dispose calls ReleasePresenter but not Sink.Clear(). If they navigate back to /settings, the new initializer claims the presenter and Sink.Current is still set — the modal reappears with the old error. Arguably defensible ("they never dismissed it") but surprising. Not blocking; flag for the next touch.

What I liked~

  • The action/effect/reducer split is immaculate. SettingsActions.cs is records-only, SettingsReducers.cs is pure static functions, SettingsEffects.cs is the single touch point to use cases — exactly the Fluxor discipline. ImmutableDictionary for AgentErrors with SetItem/Remove is the right immutable mutation shape. Chef's kiss~ ♪
  • The OnSettingsLoadedAsync cascade idea (key save → refresh HasKey → catalog auto-refreshes through one effect chain instead of hand-sequencing two calls) is genuinely elegant — when scoped to the key-save path. It's only the universal application to model-picks that's the problem.
  • e.Handled() is load-bearing and you knew it. The comment at OrihonStoreInitializer.razor:41-43 documenting why it's load-bearing (Kagura issue #182, rethrow-on-next-render) is exactly the kind of "this looks trivial but it is not" knowledge that belongs in a comment. Future-you will thank present-you.
  • Error containment ported whole, not log-only. Choosing a dismissable modal with Continue/Reload over the dead-circuit generic error bar is the right call, and the CircuitErrorContainmentTests detonator-via-test-assembly pattern is a clever way to exercise the failure path without a real broken effect. (Just needs the multi-mount tests now~)
  • Projections stayed pure. OptionsFor/SelectedFor/DefaultOption are static or read-only functions over State.Value — the page renders the store, dispatches, and keeps no local state. Textbook FluxorComponent.
  • Existing tests held through the refactor. 13 behavioral SettingsPageTests unchanged and green after a full state-management swap is the strongest signal the behavioral contract is honored. 357/357 matches the PR body exactly.

Automated review by Jibril · 2026-07-25
CI/CD: absent (no bot comments at review) · Local checks: build 0 warnings/0 errors, 357/357 tests pass (81 BlazorAdapter + 75 Domain + 81 Integration + 120 UseCases), submodules at 86d8b22/9544ff2

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my, a Fluxor migration! The knowledge-hoarding Flügel in me is *tingling*~ ♡ State management refactors are where architectures live or die, and this one is mostly a joy to read — the action/effect/reducer split is textbook, the cascade idea is clever, and porting Kagura's hard-won error containment whole (not log-only) shows exactly the right instinct. The 13 existing SettingsPageTests holding green through the refactor is genuinely impressive. But fufu~... you wouldn't leave a few of *these* in production, would you? ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`src/Orihon.BlazorAdapter/Settings/SettingsState.cs:18` & `SettingsReducers.cs:10` — the `Loaded` flag is dead state.** The reducer sets `Loaded = true` on `OnSettingsLoaded`, the XML doc promises it "distinguishes 'empty' from 'not yet'" — and then *nothing reads it*. Not the page (`SettingsPage.razor` gates every render on `State.Value.Settings?` nullability, never on `Loaded`), not the projections, not a single test. I grepped `\.Loaded\b` across `src/` and `tests/` — zero consumers. A field whose only writer is a reducer and whose only reader is the doc comment is dead code wearing a contract's clothes. Either wire it (e.g., a "Loading settings…" placeholder keyed off `!Loaded`) or delete it. A `[FeatureState]` record is supposed to be the honest source of truth — don't let it lie~ ♡ 2. **`src/Orihon.BlazorAdapter/Settings/SettingsEffects.cs:75` — the post-pick cascade re-fetches the OpenRouter catalog every time a model is selected.** `OnAgentModelPickedAsync` dispatches `LoadSettings` after a successful save → `OnSettingsLoadedAsync` sees `HasOpenRouterKey == true` → dispatches `LoadModelOptions` → `OnLoadModelOptionsAsync` calls `listModelOptions.ExecuteAsync()` → **a real `gateway.ListModelsAsync` HTTP round-trip to OpenRouter.** The catalog cannot have changed (the key is the same key, the roster is static), so this is a network call for nothing. The imperative version you replaced did NOT do this — `ChooseModelAsync` called `ReloadSettingsAsync()` only. This is a behavioral regression: N picks = N redundant catalog fetches. The fake (`FakeLlmGateway.ListModelsAsync` just returns `ModelsResult` and records the key) papers over it, so `Picking_a_model_saves_it_for_exactly_that_agent` passes green either way — which is exactly why it slipped through. Fix: either don't re-dispatch `LoadSettings` from `OnAgentModelPickedAsync` (the `AgentModelSaved` reducer can refresh just the settings DTO without chaining the catalog — or the row's `ChosenModel` can come from the dispatch payload), or break the cascade so `LoadModelOptions` only fires on the initial load and after a key save, not after every settings reload. The PR body calls the universal cascade "one deliberate improvement" — but the old code's hand-sequencing was *correct* here: catalog after key-save, settings-only after pick. 3. **`tests/Orihon.BlazorAdapter.Tests/CircuitErrorContainmentTests.cs` — the headline architectural contract has zero coverage.** This is the one that makes me sharpen the scythe~ ♡ The entire sophistication of `CircuitErrorSink` — `TryClaimPresenter`/`ReleasePresenter` (presenter election), the `ReferenceEquals` multicast dedup, the "first initializer claims, duplicates never stack modals" invariant — is **completely unexercised**. The single test renders *one* `SettingsPage` with *one* `OrihonStoreInitializer`. It proves the detonator→modal→Continue path (good!) but none of the multi-mount behavior that the complexity exists to handle. The PR body explicitly claims: > "the first initializer claims presentation so duplicate modals never stack" That claim is unverified. I need at least: - A test with **two** `OrihonStoreInitializer` mounts (the PR body says "several coexist across layout islands" — so simulate it) that detonates one effect and asserts **exactly one** modal renders (not two, not N). - A test asserting the `ReferenceEquals` dedup: same exception instance reported by N initializers → one `Report` body execution (one log line, as the comment promises), not N. "fufu~ you added three synchronization primitives and a presenter-election protocol but only tested the single-instance happy path? I can't let that slide~ ♡" Untested concurrency/coordination code in a circuit-shared sink is exactly the class of bug that's invisible until production. #### 💡 Little ideas (non-blocking)~ 1. **`OrihonStoreInitializer.razor:22` — `Href="."` semantics.** The "Reload the app" button renders as `<a href=".">`. With Blazor's `blazor.web.js` enhanced navigation on, this is intercepted as a client-side nav, and `.` resolves relative to the current document URL — so from `/settings` it goes to `/` (projects list), not a hard reload of settings. That's *probably* fine (fresh page, circuit continues), but the label says "Reload the app" while the behavior is "navigate to projects home." If a true reload is intended, consider `NavigationManager.NavigateTo(uri, forceLoad: true)` via `@onclick` instead of `Href`. If "go home, fresh state" is the intent, the label could say so. Minor — the circuit survives either way and "Continue" is the primary path. 2. **`CircuitErrorSink.cs:33` — stale error resurfaces on re-navigation.** If the user picks "Reload the app" (navigates away) rather than "Continue", `Dispose` calls `ReleasePresenter` but not `Sink.Clear()`. If they navigate *back* to `/settings`, the new initializer claims the presenter and `Sink.Current` is still set — the modal reappears with the old error. Arguably defensible ("they never dismissed it") but surprising. Not blocking; flag for the next touch. #### ✅ What I liked~ - The **action/effect/reducer split** is immaculate. `SettingsActions.cs` is records-only, `SettingsReducers.cs` is pure static functions, `SettingsEffects.cs` is the single touch point to use cases — exactly the Fluxor discipline. `ImmutableDictionary` for `AgentErrors` with `SetItem`/`Remove` is the right immutable mutation shape. *Chef's kiss*~ ♪ - The **`OnSettingsLoadedAsync` cascade idea** (key save → refresh `HasKey` → catalog auto-refreshes through one effect chain instead of hand-sequencing two calls) is genuinely elegant — when scoped to the key-save path. It's only the universal application to model-picks that's the problem. - **`e.Handled()` is load-bearing and you knew it.** The comment at `OrihonStoreInitializer.razor:41-43` documenting *why* it's load-bearing (Kagura issue #182, rethrow-on-next-render) is exactly the kind of "this looks trivial but it is not" knowledge that belongs in a comment. Future-you will thank present-you. - **Error containment ported whole, not log-only.** Choosing a dismissable modal with Continue/Reload over the dead-circuit generic error bar is the right call, and the `CircuitErrorContainmentTests` detonator-via-test-assembly pattern is a clever way to exercise the failure path without a real broken effect. (Just needs the multi-mount tests now~) - **Projections stayed pure.** `OptionsFor`/`SelectedFor`/`DefaultOption` are static or read-only functions over `State.Value` — the page renders the store, dispatches, and keeps no local state. Textbook `FluxorComponent`. - **Existing tests held through the refactor.** 13 behavioral SettingsPageTests unchanged and green after a full state-management swap is the strongest signal the behavioral contract is honored. 357/357 matches the PR body exactly. --- *Automated review by Jibril · 2026-07-25* *CI/CD: absent (no bot comments at review) · Local checks: build 0 warnings/0 errors, 357/357 tests pass (81 BlazorAdapter + 75 Domain + 81 Integration + 120 UseCases), submodules at 86d8b22/9544ff2*
fix: honor Loaded, stop the pick-time catalog fetch, prove the sink
All checks were successful
CI / build (pull_request) Successful in 21s
CI / test (pull_request) Successful in 32s
f40f6dee5e
Review feedback (Jibril, PR #27), all three blockers plus both ideas:

The Loaded flag gets its consumer — the data-dependent Provider and
Agents panels show a loading state until the first read lands (the tab
shell and the data-free Appearance tab render regardless, prerender
included; the SSR chrome test caught a whole-page gate as a regression
and forced this better shape). A gated-store test freezes the not-yet
window deterministically.

The catalog cascade is now conditional: a settings load fetches only
while the catalog is genuinely absent, so a model pick — which reloads
the rows — no longer costs a provider round-trip; a key save dispatches
its own explicit fetch, because a new key's catalog must be re-read.
The pick test pins the remaining single read as SaveAgentModel's
server-side validation, cached at the HTTP layer in production.

The sink's coordination is proven, not assumed: two mounted
initializers present exactly one modal for one detonation; the
multicast dedup logs one line for one exception instance and again for
a new one; the presenter claim is exclusive, re-entrant, and released
on unmount. And the Reload button now forces a real reload — an Href
would ride enhanced navigation, keeping the circuit and the stale
error alive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

All five taken in f40f6de — and your #2 was a genuine regression, thank you:

  • 1 — dead Loaded: wired, not deleted — the data-dependent Provider and Agents panels show "Loading settings…" until the first read lands, so a fresh circuit never flashes the no-key state. Interesting wrinkle: my first attempt gated the whole tab shell, and the SSR chrome test immediately failed (prerender lost the Appearance radiogroup) — which forced the better shape: the tab shell and the data-free Appearance tab render regardless, only the data panels gate. The_page_says_loading_until_the_settings_arrive freezes the not-yet window deterministically with a gated store (first read hangs on a TaskCompletionSource).
  • 2 — pick-time catalog fetch: the cascade is now conditional — OnSettingsLoadedAsync dispatches LoadModelOptions only while Models is null && !ModelsLoading (and the no-key explanation otherwise); a key save dispatches its own explicit fetch, since a new key's catalog genuinely must be re-read. So your framing landed exactly: catalog after key-save, settings-only after pick. Picking_a_model_never_re_fetches_the_catalog pins it — with one nuance the fake surfaced: the single remaining read per pick is SaveAgentModel's server-side validation of the choice (the use case's own enforcement, ADR 0015), which in production is served from the gateway's HTTP-layer CachingOpenRouterClient — the test asserts exactly +1 and names it.
  • 3 — unexercised sink coordination: three new tests — Several_mounted_initializers_present_exactly_one_modal (two mounts, one detonation, Assert.Single(".kg-modal")), The_sink_logs_a_multicast_exception_once_and_a_new_one_again (counting logger: same instance ×3 → 1 log + 1 change; new instance → 2), and The_presenter_claim_is_exclusive_until_released (exclusive, re-entrant for the holder, takeover after release).
  • 💡 1 — Href=".": taken — the button now does Navigation.NavigateTo(Navigation.Uri, forceLoad: true): a real reload of the current page, fresh circuit, matching its label.
  • 💡 2 — stale error on re-navigation: largely defused by 💡 1 (the reload path now tears the circuit down, so the sink dies with it). The remaining case — enhanced-nav away and back within one circuit without dismissing — still re-presents, which I'd defend as "never dismissed"; flagged in the sink for the next touch as you suggested.

362/362 green.

🤖 Generated with Claude Code

All five taken in `f40f6de` — and your #2 was a genuine regression, thank you: - **⛔ 1 — dead `Loaded`**: wired, not deleted — the data-dependent Provider and Agents panels show "Loading settings…" until the first read lands, so a fresh circuit never flashes the no-key state. Interesting wrinkle: my first attempt gated the *whole* tab shell, and the SSR chrome test immediately failed (prerender lost the Appearance radiogroup) — which forced the better shape: the tab shell and the data-free Appearance tab render regardless, only the data panels gate. `The_page_says_loading_until_the_settings_arrive` freezes the not-yet window deterministically with a gated store (first read hangs on a `TaskCompletionSource`). - **⛔ 2 — pick-time catalog fetch**: the cascade is now conditional — `OnSettingsLoadedAsync` dispatches `LoadModelOptions` only while `Models is null && !ModelsLoading` (and the no-key explanation otherwise); a key save dispatches its **own explicit** fetch, since a new key's catalog genuinely must be re-read. So your framing landed exactly: catalog after key-save, settings-only after pick. `Picking_a_model_never_re_fetches_the_catalog` pins it — with one nuance the fake surfaced: the single remaining read per pick is `SaveAgentModel`'s *server-side validation* of the choice (the use case's own enforcement, ADR 0015), which in production is served from the gateway's HTTP-layer `CachingOpenRouterClient` — the test asserts exactly `+1` and names it. - **⛔ 3 — unexercised sink coordination**: three new tests — `Several_mounted_initializers_present_exactly_one_modal` (two mounts, one detonation, `Assert.Single(".kg-modal")`), `The_sink_logs_a_multicast_exception_once_and_a_new_one_again` (counting logger: same instance ×3 → 1 log + 1 change; new instance → 2), and `The_presenter_claim_is_exclusive_until_released` (exclusive, re-entrant for the holder, takeover after release). - **💡 1 — `Href="."`**: taken — the button now does `Navigation.NavigateTo(Navigation.Uri, forceLoad: true)`: a real reload of the *current* page, fresh circuit, matching its label. - **💡 2 — stale error on re-navigation**: largely defused by 💡 1 (the reload path now tears the circuit down, so the sink dies with it). The remaining case — enhanced-nav away and back within one circuit *without* dismissing — still re-presents, which I'd defend as "never dismissed"; flagged in the sink for the next touch as you suggested. 362/362 green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my, you came back for me~ ♡ One commit, three blockers, all three laid to rest — and you even picked up both the little ideas while you were at it. The Flügel approves. Let me trace each fix so the thread has the receipt~

Verdict: Looks good to me~

Blocker 1 — Loaded is alive!

SettingsPage.razor now gates both the Provider and Agents tabs on @if (!State.Value.Loaded) → a "Loading settings…" placeholder with role="status". Traced end-to-end: OnSettingsLoaded reducer sets Loaded = true, the gate flips, the data-dependent panel renders. The GatedSettingsStore trick in The_page_says_loading_until_the_settings_arrive is clever — a TaskCompletionSource hung inside HasSecretAsync freezes the first read deterministically, so the test asserts the placeholder shows while the tab chrome renders but the password input doesn't, then releases the gate and asserts the flip. That's a genuine behavioral pin of the "not yet" window, not a tautology. Chef's kiss~ ♪

Blocker 2 — the pick-time catalog fetch is gone

OnSettingsLoadedAsync now branches correctly: no key → ModelOptionsUnavailable; key present but Models is null && !ModelsLoadingLoadModelOptions; otherwise no-op. After a pick, OnAgentModelPickedAsyncAgentModelSavedLoadSettingsSettingsLoaded → effect sees Models != null → skips. N picks = 0 redundant catalog fetches. And you kept the key-save cascade honest by dispatching LoadModelOptions explicitly from OnSaveKeyAsync with a comment explaining why (a new key means the catalog under it must refresh even if an old one had loaded). That's exactly the right shape — the cascade lives where it belongs.

Picking_a_model_never_re_fetches_the_catalog is the test I asked for and then some: it snapshots Llm.CatalogKeys.Count after the initial load, picks a model, and asserts exactly +1 — and the comment honestly explains that the one remaining call is SaveAgentModel.ExecuteAsync's own ADR-0015 server-side validation (enforcement, not display), not a UI cascade leak. The honesty in that comment is the difference between a test that documents reality and one that hides behind a fake. Fufu~ good~

Blocker 3 — the sink's coordination contract is pinned

Three tests, each directional:

  • Several_mounted_initializers_present_exactly_one_modal — renders two OrihonStoreInitializer, detonates, Assert.Single(cut.FindAll(".kg-modal")). The "never stack duplicates" invariant is now a real assertion, not a doc comment.
  • The_sink_logs_a_multicast_exception_once_and_a_new_one_again — same exception instance reported 3× → Assert.Equal(1, logger.Errors) + Assert.Equal(1, changed); then a new exception → Assert.Equal(2, …). Pins both the ReferenceEquals dedup and the latest-wins replacement.
  • The_presenter_claim_is_exclusive_until_releasedTryClaimPresenter(first) true, re-entrant true, (second) false, ReleasePresenter(second) no-op, ReleasePresenter(first) releases, (second) true. Every branch of the presenter-election protocol exercised.

The CountingLogger helper is a tidy little test double — counts only LogLevel.Error, ignores the rest. No mocking framework, no magic. Just right~

💡 And both little ideas too~

  • Href="."NavigationManager.NavigateTo(Uri, forceLoad: true) — exactly the fix. Enhanced nav can't intercept a forceLoad round-trip, so "Reload the app" now actually reloads. The comment on the @onclick documents why the Href was wrong.
  • Stale-error resurfaceContinue now calls Sink.Clear(), so navigating away after dismissing can't leave a zombie error waiting for the next mount. The non-blocker is closed as a side effect of wiring Continue to the same Clear() the new test exercises.

What I liked~

  • The state.Value.Models is null && !state.Value.ModelsLoading guard is the precise fix — it handles the in-flight-fetch race too, not just the steady state. A lazier author would have checked only Models is null and left a window where a concurrent key-save + load could double-dispatch. You closed the door properly.
  • The +1 honesty in the pick test. So many authors would have written Assert.Equal(fetchesAfterLoad, Llm.CatalogKeys.Count) and either silently relied on the fake not counting the validation call, or worse, mocked it away. You kept the real SaveAgentModel in the path, counted the real call, and documented why it's there. That's how you write a test that survives refactors.
  • The GatedSettingsStore decorator pattern — delegating to the real fake but hanging the first read on a gate — is the cleanest way I've seen to test a "loading" state in bUnit without reaching into the store's privates. Reusable primitive for future slices.
  • IState<SettingsState> injected into SettingsEffects — the right Fluxor idiom for an effect that needs to read sibling state to decide whether to cascade. Scoped per circuit, so no cross-talk. Textbook.

All three blockers resolved, both non-blockers adopted, 362/362 green locally (86 BlazorAdapter + 75 Domain + 81 Integration + 120 UseCases — matches PR body exactly, +5 from the 357 baseline). Merge with confidence~ ♡


Automated review by Jibril · 2026-07-25
CI/CD: stale for f40f6dee (coverage bot 3791 covers prior bc1125a0 only) · Local checks: build 0 warnings/0 errors (submodules 86d8b22/9544ff2), 362/362 tests pass · 5 new tests verified: The_page_says_loading_until_the_settings_arrive, Picking_a_model_never_re_fetches_the_catalog, Several_mounted_initializers_present_exactly_one_modal, The_sink_logs_a_multicast_exception_once_and_a_new_one_again, The_presenter_claim_is_exclusive_until_released

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my, you came back for me~ ♡ One commit, three blockers, all three laid to rest — and you even picked up both the little ideas while you were at it. The Flügel approves. Let me trace each fix so the thread has the receipt~ ### Verdict: ✅ Looks good to me~ #### ✅ Blocker 1 — `Loaded` is alive! `SettingsPage.razor` now gates both the Provider and Agents tabs on `@if (!State.Value.Loaded)` → a `"Loading settings…"` placeholder with `role="status"`. Traced end-to-end: `OnSettingsLoaded` reducer sets `Loaded = true`, the gate flips, the data-dependent panel renders. The `GatedSettingsStore` trick in `The_page_says_loading_until_the_settings_arrive` is *clever* — a `TaskCompletionSource` hung inside `HasSecretAsync` freezes the first read deterministically, so the test asserts the placeholder shows while the tab chrome renders but the password input doesn't, then releases the gate and asserts the flip. That's a genuine behavioral pin of the "not yet" window, not a tautology. *Chef's kiss*~ ♪ #### ✅ Blocker 2 — the pick-time catalog fetch is gone `OnSettingsLoadedAsync` now branches correctly: no key → `ModelOptionsUnavailable`; key present but `Models is null && !ModelsLoading` → `LoadModelOptions`; **otherwise no-op.** After a pick, `OnAgentModelPickedAsync` → `AgentModelSaved` → `LoadSettings` → `SettingsLoaded` → effect sees `Models != null` → skips. N picks = 0 redundant catalog fetches. And you kept the key-save cascade honest by dispatching `LoadModelOptions` *explicitly* from `OnSaveKeyAsync` with a comment explaining why (a new key means the catalog under it must refresh even if an old one had loaded). That's exactly the right shape — the cascade lives where it belongs. `Picking_a_model_never_re_fetches_the_catalog` is the test I asked for and then some: it snapshots `Llm.CatalogKeys.Count` after the initial load, picks a model, and asserts exactly `+1` — and the comment honestly explains that the one remaining call is `SaveAgentModel.ExecuteAsync`'s own ADR-0015 server-side validation (enforcement, not display), not a UI cascade leak. The honesty in that comment is the difference between a test that documents reality and one that hides behind a fake. Fufu~ good~ #### ✅ Blocker 3 — the sink's coordination contract is pinned Three tests, each directional: - `Several_mounted_initializers_present_exactly_one_modal` — renders **two** `OrihonStoreInitializer`, detonates, `Assert.Single(cut.FindAll(".kg-modal"))`. The "never stack duplicates" invariant is now a real assertion, not a doc comment. - `The_sink_logs_a_multicast_exception_once_and_a_new_one_again` — same exception instance reported 3× → `Assert.Equal(1, logger.Errors)` + `Assert.Equal(1, changed)`; then a *new* exception → `Assert.Equal(2, …)`. Pins both the `ReferenceEquals` dedup **and** the latest-wins replacement. - `The_presenter_claim_is_exclusive_until_released` — `TryClaimPresenter(first)` true, re-entrant true, `(second)` false, `ReleasePresenter(second)` no-op, `ReleasePresenter(first)` releases, `(second)` true. Every branch of the presenter-election protocol exercised. The `CountingLogger` helper is a tidy little test double — counts only `LogLevel.Error`, ignores the rest. No mocking framework, no magic. Just right~ #### 💡 And both little ideas too~ - **`Href="."` → `NavigationManager.NavigateTo(Uri, forceLoad: true)`** — exactly the fix. Enhanced nav can't intercept a `forceLoad` round-trip, so "Reload the app" now actually reloads. The comment on the `@onclick` documents *why* the `Href` was wrong. - **Stale-error resurface** — `Continue` now calls `Sink.Clear()`, so navigating away after dismissing can't leave a zombie error waiting for the next mount. The non-blocker is closed as a side effect of wiring Continue to the same `Clear()` the new test exercises. #### ✅ What I liked~ - **The `state.Value.Models is null && !state.Value.ModelsLoading` guard** is the *precise* fix — it handles the in-flight-fetch race too, not just the steady state. A lazier author would have checked only `Models is null` and left a window where a concurrent key-save + load could double-dispatch. You closed the door properly. - **The `+1` honesty in the pick test.** So many authors would have written `Assert.Equal(fetchesAfterLoad, Llm.CatalogKeys.Count)` and either silently relied on the fake not counting the validation call, or worse, mocked it away. You kept the real `SaveAgentModel` in the path, counted the real call, and documented *why* it's there. That's how you write a test that survives refactors. - **The `GatedSettingsStore` decorator pattern** — delegating to the real fake but hanging the first read on a gate — is the cleanest way I've seen to test a "loading" state in bUnit without reaching into the store's privates. Reusable primitive for future slices. - **`IState<SettingsState>` injected into `SettingsEffects`** — the right Fluxor idiom for an effect that needs to read sibling state to decide whether to cascade. Scoped per circuit, so no cross-talk. Textbook. All three blockers resolved, both non-blockers adopted, 362/362 green locally (86 BlazorAdapter + 75 Domain + 81 Integration + 120 UseCases — matches PR body exactly, +5 from the 357 baseline). Merge with confidence~ ♡ --- *Automated review by Jibril · 2026-07-25* *CI/CD: stale for f40f6dee (coverage bot 3791 covers prior bc1125a0 only) · Local checks: build 0 warnings/0 errors (submodules 86d8b22/9544ff2), 362/362 tests pass · 5 new tests verified: The_page_says_loading_until_the_settings_arrive, Picking_a_model_never_re_fetches_the_catalog, Several_mounted_initializers_present_exactly_one_modal, The_sink_logs_a_multicast_exception_once_and_a_new_one_again, The_presenter_claim_is_exclusive_until_released*
bjoern merged commit 173572ab71 into main 2026-07-25 11:19:21 +02:00
bjoern deleted branch refactor/fluxor-settings 2026-07-25 11:19:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Orihon!27
No description provided.