One state, one query set — an agent list never hops between query chains
What was seen
On Plugins CI on 2026-10-13, ThreadAgentIntegrationTest.FullFlow_CreateThread_SendMessage_StreamResponse_SaveReply
created its own agent, waited until the chat client's agent list contained it, and started a round. The round
answered:
Selected agent 'ThreadAgentIntegrationTestAgent-wnJ3…' was not found among the available agents
([Agent/Assistant, Agent/Researcher, ACME/Agent/ThreadAgentIntegrationTestAgent-z-D4K…, …]).
The 14-agent list held the agent of the class's earlier fact but not the one just created. That list was a snapshot from before the create, delivered after the client had already listed the newer state.
How the list is built
AgentPickerProjection.ObserveAgents resolves the viewer's agent query set (AiSettingsNodeType.ObserveAgentQueries).
That set depends on two inputs: the viewer's AI Settings (their agent sources) and the package roots the
viewer can read (each package's /Agent namespace is read in place). Each distinct set is served by a cached,
shared synced query — one chain per (id, query set), Replay(1) and connected for the life of the process — and
the list Switches to the chain of the newest set.
Why it went backwards
- One chain only moves forward. A chain applies the store's change notifications in order. Fifteen fresh
chains, each racing a create, never went from "has the node" back to "lacks it"
(
AgentListOneChainTest.OneChain_RacingACreate_NeverRegressesFromHasToLacks). - Two chains move independently. Each applies a write when its own notification arrives. A chain that is already built hands a NEW subscriber its last snapshot, which can predate a write that has completed. Measured: 12 of 12 subscriptions taken right after a create had completed were first handed a list without it, and caught up a few milliseconds later. That is ordinary eventual consistency (CQRS: a query is stale after a write) and harmless on its own.
- The list hopped. Both inputs used to emit a provisional value first: the package roots started with
"no packages" (
StartWith(empty)), and the settings started with the in-memory defaults (StartWith(defaults)). So every subscription resolved the provisional set, then — about 65 ms later, when the roots read answered — the real one, and switched chains. A hop from the chain that had already applied the create to one that had not hands back the older list. The round's one retry then met that chain's next emission, which still lacked the agent.
The rule
One state gives one query set; nothing provisional is emitted ahead of the real answer.
AgentPickerProjection.ObservePackageRootsemits only the roots read's authoritative answer. A failed read still degrades to no packages, loudly.AiSettingsNodeType.Observeemits only the settings node's own first answer (the defaults when it is absent), then its changes.
A genuine change — the viewer edits their AI Settings — still mints a new set and a hop; that is a real state change, not a provisional one. The cost is that the first paint of a picker waits for the roots and settings reads (a few tens of milliseconds locally); both reads complete on their own, so nothing can hang on them.
The same applies to every list built on these two inputs: ObserveSkillQueries and ObserveModelQueries.
Tests
MeshWeaver.AI.Test/AgentListOneChainTest:
TheAgentQuerySet_IsResolvedOnce_NeverAProvisionalSetFirst— one query set per subscription; two before the fix. Control: the fixture mesh has package roots, and the one set is the package-widened one.ASignedInViewersAgentQuerySet_IsResolvedOnce_NeverTheDefaultsFirst— a signed-in viewer whose settings add an agent source: the settings stream emits their own settings once (twice before the fix, defaults first), and the agent query set is resolved once. Control: their settings resolve a different set than the defaults.AnAgentListThatHasListedAnAgent_NeverDropsIt— the CI symptom as a property over fresh clients.OneChain_RacingACreate_NeverRegressesFromHasToLacks— the property the rule relies on.