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)");