diff --git a/src/MeshWeaver.AI/HubThreadExtensions.cs b/src/MeshWeaver.AI/HubThreadExtensions.cs
index d68f1d28ab..99b620166b 100644
--- a/src/MeshWeaver.AI/HubThreadExtensions.cs
+++ b/src/MeshWeaver.AI/HubThreadExtensions.cs
@@ -4,6 +4,7 @@
using MeshWeaver.Graph;
using MeshWeaver.Layout;
using MeshWeaver.Mesh;
+using MeshWeaver.Mesh.Security;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Messaging;
using Microsoft.Extensions.DependencyInjection;
@@ -43,25 +44,47 @@ public static class HubThreadExtensions
/// one moment the originating user's identity is reliably on the AsyncLocal
/// (AccessService.Context) or the per-circuit fallback (CircuitContext). The
/// captured (ObjectId, Name) is stamped onto the pending
- /// ( / )
+ /// ( / /
+ /// )
/// so the round-dispatch watcher can rebuild the identity AFTER every later async boundary
/// (its own .Subscribe continuation + the AI streaming continuations) has wiped the
/// AsyncLocal. This is "capture the identity when computing the submit patch" β the data
/// carries the truth, not the (by-then-null) AsyncLocal.
///
- /// Returns (null, null) when no real user identity is live (e.g. a hub-shaped
- /// principal, or no context at all) β the watcher then falls back to the thread owner derived
- /// from the node, NEVER hub-self. A hub-shaped principal is explicitly rejected here so it can
- /// never be persisted as a fake submitter (the CreatedBy=sync/β¦ class of bug).
+ /// π The snapshot includes the submitter's : every
+ /// user-facing string a round emits is resolved off the round's context, so a rebuilt context
+ /// without the language renders English for a German user (#948). The language is part of the
+ /// identity, and this is the one place it is reliably live.
+ ///
+ /// The first REAL USER wins, not the first non-null context. The request-scoped
+ /// Context frequently holds a NON-user principal at the submit boundary β a hub-shaped
+ /// address, or system-security from an enclosing ImpersonateAsSystem scope
+ /// (seeding, hydration, any infrastructure step the submit is nested in). Taking it merely
+ /// because it is non-null stamps THAT as the submitter and discards the real signed-in user
+ /// sitting right there on the circuit context β which cost both the identity AND the language
+ /// (#948: a German user's round rendered English because system-security shadowed
+ /// Roland/de). This is the same IsRealUser predicate the round-dispatch watcher
+ /// applies at the other end of the rider; the two ends now agree.
+ ///
+ /// Returns all-null when no real user identity is live at all β the watcher then falls
+ /// back to the thread owner derived from the node, NEVER hub-self and never System.
///
- private static (string? ObjectId, string? Name) CaptureSubmitter(this IMessageHub hub)
+ private static (string? ObjectId, string? Name, string? Locale) CaptureSubmitter(this IMessageHub hub)
{
var accessService = hub.ServiceProvider.GetService();
- var ctx = accessService?.Context ?? accessService?.CircuitContext;
- if (ctx is null || string.IsNullOrEmpty(ctx.ObjectId)
- || AccessService.LooksLikeHubPrincipal(ctx.ObjectId))
- return (null, null);
- return (ctx.ObjectId, string.IsNullOrEmpty(ctx.Name) ? ctx.ObjectId : ctx.Name);
+ var ctx = IsRealUser(accessService?.Context) ? accessService!.Context
+ : IsRealUser(accessService?.CircuitContext) ? accessService!.CircuitContext
+ : null;
+ if (ctx is null)
+ return (null, null, null);
+ return (ctx.ObjectId, string.IsNullOrEmpty(ctx.Name) ? ctx.ObjectId : ctx.Name, ctx.Locale);
+
+ // A submitter is a PERSON. Hub addresses (sync/, mesh/, node/, activity/, portal/) and the
+ // well-known System identity are infrastructure credentials β never a submitter.
+ static bool IsRealUser(AccessContext? candidate) =>
+ !string.IsNullOrEmpty(candidate?.ObjectId)
+ && !AccessService.LooksLikeHubPrincipal(candidate.ObjectId)
+ && !string.Equals(candidate.ObjectId, WellKnownUsers.System, StringComparison.Ordinal);
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -138,7 +161,7 @@ public static void StartThread(
// Capture the submitter's identity NOW β synchronous, live AsyncLocal β and persist it on the
// pending message (below) so the round-dispatch watcher can rebuild it AFTER the async boundary
// wipes the AsyncLocal. See ThreadMessage.SubmitterObjectId / AccessContextPropagation.md.
- var (submitterObjectId, submitterName) = hub.CaptureSubmitter();
+ var (submitterObjectId, submitterName, submitterLocale) = hub.CaptureSubmitter();
// π― The thread's COMPOSER is the single source of truth for the round's sticky
// selection (agent / model / harness / context). Seed it from the supplied composer
@@ -180,7 +203,8 @@ public static void StartThread(
attachments: attachments,
harness: harness,
submitterObjectId: submitterObjectId,
- submitterName: submitterName))
+ submitterName: submitterName,
+ submitterLocale: submitterLocale))
}
: baseThread; // empty thread β no round
threadNode = threadNode with { Content = seededThread };
@@ -311,7 +335,7 @@ public static void SubmitMessage(
if (string.IsNullOrWhiteSpace(userText))
return;
- var (submitterObjectId, submitterName) = hub.CaptureSubmitter();
+ var (submitterObjectId, submitterName, submitterLocale) = hub.CaptureSubmitter();
var userMessage = ThreadInput.CreateUserMessage(
userText ?? string.Empty,
createdBy: createdBy,
@@ -322,7 +346,8 @@ public static void SubmitMessage(
attachments: attachments,
harness: harness,
submitterObjectId: submitterObjectId,
- submitterName: submitterName);
+ submitterName: submitterName,
+ submitterLocale: submitterLocale);
try
{
ThreadInput.AppendUserInput(hub.GetWorkspace(), threadPath, userMessage);
@@ -375,7 +400,7 @@ public static void SubmitComposer(
var logger = hub.ServiceProvider.GetService()
?.CreateLogger("MeshWeaver.AI.HubThreadExtensions");
var msgId = Guid.NewGuid().ToString("N")[..8];
- var (submitterObjectId, submitterName) = hub.CaptureSubmitter();
+ var (submitterObjectId, submitterName, submitterLocale) = hub.CaptureSubmitter();
hub.GetWorkspace().GetMeshNodeStream(threadPath).Update(node =>
{
@@ -399,7 +424,8 @@ public static void SubmitComposer(
attachments: attachments ?? c.Attachments,
harness: c.Harness,
submitterObjectId: submitterObjectId,
- submitterName: submitterName);
+ submitterName: submitterName,
+ submitterLocale: submitterLocale);
return node with
{
diff --git a/src/MeshWeaver.AI/Thread.cs b/src/MeshWeaver.AI/Thread.cs
index 32f2bc98d2..6469bdf953 100644
--- a/src/MeshWeaver.AI/Thread.cs
+++ b/src/MeshWeaver.AI/Thread.cs
@@ -555,6 +555,25 @@ public record ThreadMessage
///
public string? SubmitterName { get; init; }
+ ///
+ /// The submitter's β their preferred UI language as a
+ /// BCP-47 tag (de, en, β¦) β captured alongside
+ /// at submit time. Null OR EMPTY when the submitter has no language preference β the value is
+ /// copied verbatim from , whose own contract is
+ /// null/empty/unsupported β English, so both absences reach here unchanged. Test it with
+ /// , never a null check alone.
+ ///
+ /// π Why the LANGUAGE has to ride on the message too. The round-dispatch watcher
+ /// rebuilds the round's from this rider, because its
+ /// Throttle/Subscribe continuation has no live identity. Every user-facing string a round
+ /// emits is resolved explicitly off AccessContext.Locale (never ambient
+ /// CultureInfo β a round hops schedulers), so a rebuilt context that carries only
+ /// + renders the DEFAULT language
+ /// for every viewer regardless of theirs. The identity's own presentation preference is part
+ /// of the identity: carry it on the same rider (#948).
+ ///
+ public string? SubmitterLocale { get; init; }
+
///
/// Completed tool calls from this message's execution.
/// Populated when execution finishes, used for post-execution inspection.
diff --git a/src/MeshWeaver.AI/ThreadInput.cs b/src/MeshWeaver.AI/ThreadInput.cs
index d02d23b401..98691f88af 100644
--- a/src/MeshWeaver.AI/ThreadInput.cs
+++ b/src/MeshWeaver.AI/ThreadInput.cs
@@ -34,6 +34,10 @@ public static class ThreadInput
/// .
/// The submitter's , captured
/// alongside .
+ /// The submitter's , captured
+ /// alongside so the rebuilt round context can render
+ /// user-facing text in the submitter's language. See
+ /// .
public static ThreadMessage CreateUserMessage(
string text,
string? createdBy = null,
@@ -44,7 +48,8 @@ public static ThreadMessage CreateUserMessage(
IReadOnlyList? attachments = null,
string? harness = null,
string? submitterObjectId = null,
- string? submitterName = null) =>
+ string? submitterName = null,
+ string? submitterLocale = null) =>
new()
{
Role = "user",
@@ -58,6 +63,7 @@ public static ThreadMessage CreateUserMessage(
Attachments = attachments,
SubmitterObjectId = submitterObjectId,
SubmitterName = submitterName,
+ SubmitterLocale = submitterLocale,
Timestamp = DateTime.UtcNow,
Type = ThreadMessageType.ExecutedInput,
// User cells don't have a streaming lifecycle β Submitted on creation.
diff --git a/src/MeshWeaver.AI/ThreadSubmission.cs b/src/MeshWeaver.AI/ThreadSubmission.cs
index dbe3e22316..5f06ea93b9 100644
--- a/src/MeshWeaver.AI/ThreadSubmission.cs
+++ b/src/MeshWeaver.AI/ThreadSubmission.cs
@@ -752,14 +752,30 @@ bool IsRealUser(AccessContext? ctx) =>
// β the authoritative identity that survives every async boundary. Prefer it over the
// wrapping-node CreatedBy fallback so the round's cross-hub cell/state writes carry the real
// user and never post a null context β fail closed β stuck cell / wedge. See AccessContextPropagation.md.
- if (userCtx is null)
- {
- var submitter = dispatch.UserMessageIds
- .Select(mid => thread.PendingUserMessages.TryGetValue(mid, out var m) ? m : null)
- .LastOrDefault(m => !string.IsNullOrEmpty(m?.SubmitterObjectId));
- if (submitter?.SubmitterObjectId is { } sid)
- userCtx = new AccessContext { ObjectId = sid, Name = submitter.SubmitterName ?? sid };
- }
+ var submitter = dispatch.UserMessageIds
+ .Select(mid => thread.PendingUserMessages.TryGetValue(mid, out var m) ? m : null)
+ .LastOrDefault(m => !string.IsNullOrEmpty(m?.SubmitterObjectId));
+ if (userCtx is null && submitter?.SubmitterObjectId is { } sid)
+ userCtx = new AccessContext
+ {
+ ObjectId = sid,
+ Name = submitter.SubmitterName ?? sid,
+ // π The submitter's LANGUAGE rides on the same rider as their identity. Every
+ // user-facing string this round emits is resolved off AccessContext.Locale, so a
+ // context rebuilt without it renders English for every viewer (#948).
+ Locale = submitter.SubmitterLocale
+ };
+ // π β¦and when the identity came from an AMBIENT context above, that context is
+ // locale-less BY CONSTRUCTION: the thread hub's OWNER-INJECTION (ThreadExecution) stamps
+ // the owner as {ObjectId, Name} only β no presentation preferences β onto the shared
+ // AccessService, and it is that injected context the watcher usually observes here. The
+ // per-round rider is the only carrier of the submitter's language, so take it from there
+ // whichever branch supplied the identity. Without this the round speaks English to a
+ // German submitter even though the rider knew better (#948).
+ else if (userCtx is not null
+ && string.IsNullOrEmpty(userCtx.Locale)
+ && !string.IsNullOrEmpty(submitter?.SubmitterLocale))
+ userCtx = userCtx with { Locale = submitter.SubmitterLocale };
var fellBackToCreatedBy = false;
// Resolution: thread content's CreatedBy β wrapping node's CreatedBy β null.
@@ -768,6 +784,11 @@ bool IsRealUser(AccessContext? ctx) =>
: threadNode.CreatedBy;
if (userCtx is null && !string.IsNullOrEmpty(resolvedCreatedBy))
{
+ // π No Locale on this path, deliberately: CreatedBy is a bare user id stamped on the
+ // node β it carries no presentation preferences, and this branch runs when there is no
+ // submitter rider at all (a resumed / orphaned round). The round therefore speaks the
+ // DEFAULT language, which is the honest answer when nothing on the data says otherwise;
+ // inventing one (e.g. reading the ambient culture) would be worse (#948).
userCtx = new AccessContext { ObjectId = resolvedCreatedBy, Name = resolvedCreatedBy };
fellBackToCreatedBy = true;
}
@@ -779,7 +800,8 @@ bool IsRealUser(AccessContext? ctx) =>
logger?.LogInformation(
"[ThreadSubmission] DispatchRound identity thread={ThreadPath} responseId={ResponseId} " +
"asyncLocal={AsyncLocal} hubAsUserMatch={HubAsUser} circuit={Circuit} threadCreatedBy={ThreadCreatedBy} " +
- "nodeCreatedBy={NodeCreatedBy} fallbackToCreatedBy={FallbackToCreatedBy} effective={Effective}",
+ "nodeCreatedBy={NodeCreatedBy} fallbackToCreatedBy={FallbackToCreatedBy} effective={Effective} "
+ + "locale={Locale}",
threadPath, responseMsgId,
asyncLocalCtx?.ObjectId ?? "(null)",
hubAsUserMatch,
@@ -787,7 +809,13 @@ bool IsRealUser(AccessContext? ctx) =>
thread.CreatedBy ?? "(null)",
threadNode.CreatedBy ?? "(null)",
fellBackToCreatedBy,
- userCtx?.ObjectId ?? "(null)");
+ userCtx?.ObjectId ?? "(null)",
+ // π The language the round will speak β the one thing that makes a "renders English
+ // for a German user" report diagnosable from the log alone (#948). IsNullOrEmpty, not
+ // ??: AccessContext.Locale's contract is null/empty/unsupported β English, so an empty
+ // string is a real "no preference" and must read as "(default)", not as a blank gap
+ // that looks like the trace itself failed.
+ string.IsNullOrEmpty(userCtx?.Locale) ? "(default)" : userCtx!.Locale);
var meshService = hub.ServiceProvider.GetRequiredService();
diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-round-messages-in-your-language.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-round-messages-in-your-language.md
new file mode 100644
index 0000000000..05bbb2ec70
--- /dev/null
+++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-round-messages-in-your-language.md
@@ -0,0 +1,18 @@
+---
+Name: Chat rounds speak your language
+Category: What's New
+Description: Messages a chat round writes back β like "no language model can serve this round" β now appear in the language you picked, not always English.
+Icon: Sparkle
+---
+
+# Chat rounds speak your language
+
+When a chat round has something to tell you β for example that no language model can currently
+serve it β that message is now shown in the language you selected, not always in English.
+
+Until now those messages were written by a background step that had lost track of who submitted
+the message, and with it your language preference. Your name and account were carried across, but
+your language was not, so the text fell back to English for everyone.
+
+Your language now travels with your message all the way to the round that answers it. This applies
+to every message a round writes back, including ones added in future.
diff --git a/test/MeshWeaver.AI.Test/ModelSubstitutionTest.cs b/test/MeshWeaver.AI.Test/ModelSubstitutionTest.cs
index d114165ae8..e8269d8e7c 100644
--- a/test/MeshWeaver.AI.Test/ModelSubstitutionTest.cs
+++ b/test/MeshWeaver.AI.Test/ModelSubstitutionTest.cs
@@ -134,6 +134,12 @@ await Observable.Interval(TimeSpan.FromMilliseconds(50))
/// (c): NOTHING resolves β the requested model is unusable and the catalog offers no usable
/// replacement, so no agent can be built. The round must FAIL with a message that names the
/// situation, not settle as a success carrying a raw provider error.
+ ///
+ /// π The round submits under a GERMAN submitter (#948) and asserts the GERMAN catalog
+ /// text. Asserting the English entry (locale: null) would pass equally against a
+ /// hard-coded English literal AND against a round that lost the submitter's locale β which is
+ /// exactly the defect this pins: the submission watcher rebuilds the round's identity from the
+ /// submitter rider on the pending message, so the locale has to ride there too.
///
[Fact(Timeout = 120_000)]
public async Task NoUsableModelAnywhere_FailsRoundWithSpeakingError()
@@ -153,9 +159,31 @@ await Observable.Interval(TimeSpan.FromMilliseconds(50))
resolver.ResolveDefaultModelId().Should().BeNull(
"the precondition for this test is that NO model in the catalog has a usable credential");
+ // π The assertion below only bites if EN and DE actually differ for this key β otherwise
+ // "renders German" and "renders English" are the same string and the test goes vacuous.
+ var germanText = LocalizationCatalog.Get("chat.noUsableModel", "de", StaleModel);
+ var englishText = LocalizationCatalog.Get("chat.noUsableModel", locale: null, StaleModel);
+ germanText.Should().NotBe(englishText,
+ "the German and English catalog entries must differ, or asserting the German text "
+ + "would pass against an English round and prove nothing (#948)");
+
var threadPath = await SeedThread();
var client = GetClient();
- client.SubmitMessage(threadPath, "hello", modelName: StaleModel, createdBy: TestUser);
+
+ // Submit as a GERMAN-speaking user. The submitter's identity β locale included β is
+ // captured at the submit boundary and rides on the pending message, because the submission
+ // watcher's Throttle/Subscribe continuation has no live AccessContext (#948).
+ var access = Mesh.ServiceProvider.GetRequiredService();
+ var previousContext = access.CircuitContext;
+ access.SetCircuitContext((previousContext ?? TestUsers.Admin) with { Locale = "de" });
+ try
+ {
+ client.SubmitMessage(threadPath, "hello", modelName: StaleModel, createdBy: TestUser);
+ }
+ finally
+ {
+ access.SetCircuitContext(previousContext);
+ }
var thread = await WaitForThread(threadPath,
t => t.Status == ThreadExecutionStatus.Idle && t.Messages.Count >= 2, 60_000);
@@ -166,11 +194,20 @@ await Observable.Interval(TimeSpan.FromMilliseconds(50))
cell.Status.Should().Be(ThreadMessageStatus.Error,
"a round that no model can serve must FAIL β settling as Completed reads as success to "
+ "every automation that checks the node (#476)");
- // The failure must SPEAK β and speak the viewer's language: it comes from the localization
- // catalog, not a hard-coded English literal, and it names the requested model.
- cell.Summary.Should().Be(
- LocalizationCatalog.Get("chat.noUsableModel", locale: null, StaleModel),
- "the round's failure text must be the localized catalog message for this situation");
+
+ // The mechanism, pinned: the submitter's language rides on the user message next to their
+ // identity. That rider is what the round-dispatch watcher rebuilds the round's
+ // AccessContext from, so if it loses the locale every round speaks English (#948).
+ var userCell = await WaitForCell(threadPath, thread.Messages[0], _ => true, 30_000);
+ userCell.SubmitterLocale.Should().Be("de",
+ "the submit boundary must capture the submitter's language onto the message β it is the "
+ + "only carrier that survives the watcher's Throttle/Subscribe hop (#948)");
+ // The failure must SPEAK β and speak the SUBMITTER's language: it comes from the
+ // localization catalog resolved off the round's own AccessContext.Locale, not a hard-coded
+ // English literal, and it names the requested model.
+ cell.Summary.Should().Be(germanText,
+ "the round's failure text must be the catalog message in the submitter's language β "
+ + "the round's AccessContext must carry the submitter's Locale (#948)");
cell.Summary.Should().Contain(StaleModel, "the message must name the model that was requested");
cell.Summary.Should().NotContain("ApiKey",
"the raw provider/factory error belongs in the log, never pasted into the thread (#476)");