Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 43 additions & 17 deletions src/MeshWeaver.AI/HubThreadExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,25 +44,47 @@ public static class HubThreadExtensions
/// one moment the originating user's identity is reliably on the AsyncLocal
/// (<c>AccessService.Context</c>) or the per-circuit fallback (<c>CircuitContext</c>). The
/// captured <c>(ObjectId, Name)</c> is stamped onto the pending <see cref="ThreadMessage"/>
/// (<see cref="ThreadMessage.SubmitterObjectId"/> / <see cref="ThreadMessage.SubmitterName"/>)
/// (<see cref="ThreadMessage.SubmitterObjectId"/> / <see cref="ThreadMessage.SubmitterName"/> /
/// <see cref="ThreadMessage.SubmitterLocale"/>)
/// so the round-dispatch watcher can rebuild the identity AFTER every later async boundary
/// (its own <c>.Subscribe</c> 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.
///
/// <para>Returns <c>(null, null)</c> 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 <c>CreatedBy=sync/…</c> class of bug).</para>
/// <para>🌍 The snapshot includes the submitter's <see cref="AccessContext.Locale"/>: 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.</para>
///
/// <para><b>The first REAL USER wins, not the first non-null context.</b> The request-scoped
/// <c>Context</c> frequently holds a NON-user principal at the submit boundary — a hub-shaped
/// address, or <c>system-security</c> from an enclosing <c>ImpersonateAsSystem</c> 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 <c>system-security</c> shadowed
/// <c>Roland/de</c>). This is the same <c>IsRealUser</c> predicate the round-dispatch watcher
/// applies at the other end of the rider; the two ends now agree.</para>
///
/// <para>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.</para>
/// </summary>
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<AccessService>();
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);
}

// ═════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -375,7 +400,7 @@ public static void SubmitComposer(
var logger = hub.ServiceProvider.GetService<ILoggerFactory>()
?.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 =>
{
Expand All @@ -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
{
Expand Down
19 changes: 19 additions & 0 deletions src/MeshWeaver.AI/Thread.cs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,25 @@ public record ThreadMessage
/// </summary>
public string? SubmitterName { get; init; }

/// <summary>
/// The submitter's <see cref="AccessContext.Locale"/> — their preferred UI language as a
/// BCP-47 tag (<c>de</c>, <c>en</c>, …) — captured alongside <see cref="SubmitterObjectId"/>
/// at submit time. Null OR EMPTY when the submitter has no language preference — the value is
/// copied verbatim from <see cref="AccessContext.Locale"/>, whose own contract is
/// null/empty/unsupported → English, so both absences reach here unchanged. Test it with
/// <see cref="string.IsNullOrEmpty(string)"/>, never a null check alone.
///
/// <para><b>🌍 Why the LANGUAGE has to ride on the message too.</b> The round-dispatch watcher
/// rebuilds the round's <see cref="AccessContext"/> from this rider, because its
/// Throttle/Subscribe continuation has no live identity. Every user-facing string a round
/// emits is resolved explicitly off <c>AccessContext.Locale</c> (never ambient
/// <c>CultureInfo</c> — a round hops schedulers), so a rebuilt context that carries only
/// <see cref="SubmitterObjectId"/> + <see cref="SubmitterName"/> 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).</para>
/// </summary>
public string? SubmitterLocale { get; init; }

/// <summary>
/// Completed tool calls from this message's execution.
/// Populated when execution finishes, used for post-execution inspection.
Expand Down
8 changes: 7 additions & 1 deletion src/MeshWeaver.AI/ThreadInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public static class ThreadInput
/// <see cref="ThreadMessage.SubmitterObjectId"/>.</param>
/// <param name="submitterName">The submitter's <see cref="AccessContext.Name"/>, captured
/// alongside <paramref name="submitterObjectId"/>.</param>
/// <param name="submitterLocale">The submitter's <see cref="AccessContext.Locale"/>, captured
/// alongside <paramref name="submitterObjectId"/> so the rebuilt round context can render
/// user-facing text in the submitter's language. See
/// <see cref="ThreadMessage.SubmitterLocale"/>.</param>
public static ThreadMessage CreateUserMessage(
string text,
string? createdBy = null,
Expand All @@ -44,7 +48,8 @@ public static ThreadMessage CreateUserMessage(
IReadOnlyList<string>? attachments = null,
string? harness = null,
string? submitterObjectId = null,
string? submitterName = null) =>
string? submitterName = null,
string? submitterLocale = null) =>
new()
{
Role = "user",
Expand All @@ -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.
Expand Down
48 changes: 38 additions & 10 deletions src/MeshWeaver.AI/ThreadSubmission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
Expand All @@ -779,15 +800,22 @@ 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,
circuitCtx?.ObjectId ?? "(null)",
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<IMeshService>();

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading