From 293833f0a6ad317cc8c80bfa1563e6a4ebd800f6 Mon Sep 17 00:00:00 2001
From: Vladyslav Horbachov <0965142838gvs@gmail.com>
Date: Tue, 28 Jul 2026 06:59:42 +0200
Subject: [PATCH 01/20] chore: complete xunit v4 platform migration
---
Directory.Packages.props | 6 ++---
global.json | 3 +++
os/DigitalBrain.OS.AppHost/AppHost.cs | 6 +----
.../AppHostCleanupContracts.cs | 1 +
.../AIProviderConfiguration.cs | 25 +++++++++++--------
5 files changed, 23 insertions(+), 18 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8e4ff58e..5c1e3818 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,8 +4,8 @@
-
-
+
+
@@ -37,4 +37,4 @@
-
\ No newline at end of file
+
diff --git a/global.json b/global.json
index ceef7c50..5c62de86 100644
--- a/global.json
+++ b/global.json
@@ -3,5 +3,8 @@
"version": "11.0.100-preview.6.26359.118",
"rollForward": "latestFeature",
"allowPrerelease": true
+ },
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
}
}
diff --git a/os/DigitalBrain.OS.AppHost/AppHost.cs b/os/DigitalBrain.OS.AppHost/AppHost.cs
index d1c8a2bb..d291a24c 100644
--- a/os/DigitalBrain.OS.AppHost/AppHost.cs
+++ b/os/DigitalBrain.OS.AppHost/AppHost.cs
@@ -16,11 +16,7 @@
var brain = builder.AddDigitalBrain(ProductSurfaceResources.Brain);
brain.AddModule(ai => ai
- .WithLlm()
- //.WithLlm()
- //.WithLlm()
- //.WithLlm()
- );
+ .WithLlm());
brain.AddModule();
brain.AddModule();
brain.AddModule(flutter => flutter
diff --git a/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs b/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs
index a3ced2e5..2483cc80 100644
--- a/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs
+++ b/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs
@@ -7,6 +7,7 @@
using Microsoft.Extensions.Hosting;
using Xunit;
using Xunit.Sdk;
+using Xunit.v3;
[assembly: Parallelization(Mode = ParallelMode.None)]
diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs
index 49683a96..3a28d9d7 100644
--- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs
+++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs
@@ -25,11 +25,13 @@ public sealed class AIProviderConfiguration
"file:///tmp/ollama",
];
- public static TheoryData ValidOllamaEndpoints =>
- [
- "http://ollama.example.test:11434",
- "https://ollama.example.test:11435",
- ];
+ public static TheoryData SupportedOllamaModels => new()
+ {
+ { typeof(Llama32), "llama3.2", "http://ollama.example.test:11434" },
+ { typeof(Gemma4), "gemma4:12b", "https://ollama.example.test:11435" },
+ { typeof(Qwen35), "qwen3.5:9b", "http://ollama.example.test:11436" },
+ { typeof(Granite41), "granite4.1:8b", "https://ollama.example.test:11437" },
+ };
[Theory(DisplayName = "missing, blank, and malformed Ollama endpoints fail before client contact")]
[MemberData(nameof(InvalidOllamaEndpoints))]
@@ -43,18 +45,21 @@ public void InvalidOllamaEndpointsFailBeforeClientContact(string? endpoint)
Assert.Contains(OllamaEndpointKey, exception.Message, StringComparison.Ordinal);
}
- [Theory(DisplayName = "an explicit HTTP(S) Ollama endpoint resolves only the selected Llama32 client")]
- [MemberData(nameof(ValidOllamaEndpoints))]
- public void ExplicitOllamaEndpointResolvesSelectedClient(string endpoint)
+ [Theory(DisplayName = "every supported Ollama model resolves with its expected default tag")]
+ [MemberData(nameof(SupportedOllamaModels))]
+ public void SupportedOllamaModelResolvesWithExpectedDefaultTag(
+ Type modelType,
+ string expectedTag,
+ string endpoint)
{
using var provider = Compose(endpoint);
- var client = provider.GetRequiredKeyedService(typeof(Llama32));
+ var client = provider.GetRequiredKeyedService(modelType);
var ollama = Assert.IsAssignableFrom(client);
Assert.Equal(new Uri(endpoint), ollama.Uri);
Assert.False(string.Equals("localhost", ollama.Uri.Host, StringComparison.OrdinalIgnoreCase));
- Assert.Equal("llama3.2", ollama.SelectedModel);
+ Assert.Equal(expectedTag, ollama.SelectedModel);
}
private static ServiceProvider Compose(string? endpoint)
From 40ed15a6bdeca372f0e9867458366448750eb9a5 Mon Sep 17 00:00:00 2001
From: Vladyslav Horbachov <0965142838gvs@gmail.com>
Date: Tue, 28 Jul 2026 07:08:23 +0200
Subject: [PATCH 02/20] feat: add observable service defaults
---
DigitalBrain.slnx | 2 +
Directory.Packages.props | 8 ++
.../DigitalBrain.OS.Host.csproj | 1 +
os/DigitalBrain.OS.Host/Program.cs | 7 +-
.../DigitalBrain.OS.Mcp.csproj | 1 +
os/DigitalBrain.OS.Mcp/McpHost.cs | 3 -
os/DigitalBrain.OS.Mcp/Program.cs | 3 +
.../DigitalBrain.ServiceDefaults.Tests.csproj | 17 +++
.../ServiceDefaultsContracts.cs | 51 ++++++++
.../DigitalBrain.ServiceDefaults.csproj | 14 +++
.../ServiceDefaultsExtensions.cs | 118 ++++++++++++++++++
.../AIProviderConfiguration.cs | 4 +-
.../Clients/AIClients.cs | 18 ++-
.../UIEdgeVocabulary.cs | 4 -
.../UIFixture.cs | 3 +
.../DigitalBrain.Modules.Flutter.Http.csproj | 1 +
.../Program.cs | 3 +
.../UIEdgeContract.cs | 2 -
.../UIHost.cs | 1 -
19 files changed, 243 insertions(+), 18 deletions(-)
create mode 100644 src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj
create mode 100644 src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs
create mode 100644 src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj
create mode 100644 src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs
diff --git a/DigitalBrain.slnx b/DigitalBrain.slnx
index b1eb4772..c186efa5 100644
--- a/DigitalBrain.slnx
+++ b/DigitalBrain.slnx
@@ -12,6 +12,8 @@
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5c1e3818..f8624bc6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,8 +24,16 @@
+
+
+
+
+
+
+
+
diff --git a/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj b/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj
index d4d4b75b..2bc0b499 100644
--- a/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj
+++ b/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj
@@ -11,6 +11,7 @@
+
diff --git a/os/DigitalBrain.OS.Host/Program.cs b/os/DigitalBrain.OS.Host/Program.cs
index 0538e94f..9ed5ed8f 100644
--- a/os/DigitalBrain.OS.Host/Program.cs
+++ b/os/DigitalBrain.OS.Host/Program.cs
@@ -1,10 +1,9 @@
using DigitalBrain.Kernel;
-
-const string HealthPath = "/health";
-const string HealthResponse = "healthy";
+using DigitalBrain.ServiceDefaults;
var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
builder.AddKeyedAzureTableServiceClient("brain-clustering");
builder.AddKeyedAzureTableServiceClient("brain-reminders");
builder.UseOrleans(silo => silo
@@ -12,5 +11,5 @@
.AddDigitalBrainJournalStorage(builder.Configuration));
var app = builder.Build();
-app.MapGet(HealthPath, static () => Results.Ok(HealthResponse));
+app.MapDefaultEndpoints();
app.Run();
diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj
index 6c10b917..08c22eca 100644
--- a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj
+++ b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj
@@ -12,6 +12,7 @@
+
diff --git a/os/DigitalBrain.OS.Mcp/McpHost.cs b/os/DigitalBrain.OS.Mcp/McpHost.cs
index fecae8c3..28e8c3c0 100644
--- a/os/DigitalBrain.OS.Mcp/McpHost.cs
+++ b/os/DigitalBrain.OS.Mcp/McpHost.cs
@@ -3,8 +3,6 @@ namespace DigitalBrain.OS.Mcp;
internal static class McpHost
{
public const string EndpointPath = "/mcp";
- public const string HealthPath = "/health";
- public const string HealthResponse = "healthy";
public const string AskLlama32ToolName = "ask_llama32";
public const string DefaultLlama32Key = "default";
public const string ListActiveNeuronsToolName = "list_active_neurons";
@@ -15,7 +13,6 @@ public static WebApplication MapMcpHost(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
- app.MapGet(HealthPath, static () => Results.Ok(HealthResponse));
app.MapMcp(EndpointPath);
return app;
}
diff --git a/os/DigitalBrain.OS.Mcp/Program.cs b/os/DigitalBrain.OS.Mcp/Program.cs
index 06111ac1..1c5f542a 100644
--- a/os/DigitalBrain.OS.Mcp/Program.cs
+++ b/os/DigitalBrain.OS.Mcp/Program.cs
@@ -1,10 +1,12 @@
using DigitalBrain.Aspire;
using DigitalBrain.OS.Mcp;
+using DigitalBrain.ServiceDefaults;
using Microsoft.Extensions.AI;
using Orleans.Serialization;
var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
builder.AddKeyedAzureTableServiceClient("brain-clustering");
builder.AddDigitalBrainClient(client =>
{
@@ -19,5 +21,6 @@
.WithTools();
var app = builder.Build();
+app.MapDefaultEndpoints();
app.MapMcpHost();
app.Run();
diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj
new file mode 100644
index 00000000..53b92bd0
--- /dev/null
+++ b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ $(NoWarn);CA2007;CA1515
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs
new file mode 100644
index 00000000..02927790
--- /dev/null
+++ b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs
@@ -0,0 +1,51 @@
+using DigitalBrain.ServiceDefaults;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+using Xunit;
+
+namespace DigitalBrain.ServiceDefaultsTests;
+
+public sealed class ServiceDefaultsContracts
+{
+ [Fact(DisplayName = "service defaults expose healthy readiness and liveness endpoints")]
+ public async Task ServiceDefaultsExposeHealthyReadinessAndLivenessEndpoints()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] = "";
+ builder.AddServiceDefaults();
+
+ await using var app = builder.Build();
+ app.MapDefaultEndpoints();
+
+ var routes = ((IEndpointRouteBuilder)app).DataSources
+ .SelectMany(static source => source.Endpoints)
+ .OfType()
+ .Select(static endpoint => endpoint.RoutePattern.RawText)
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+ var health = await app.Services
+ .GetRequiredService()
+ .CheckHealthAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(HealthStatus.Healthy, health.Status);
+ Assert.Equal(["/alive", "/health"], routes);
+ }
+
+ [Fact(DisplayName = "service defaults register tracing, metrics, and resilient HTTP clients")]
+ public async Task ServiceDefaultsRegisterTelemetryAndResilientHttpClients()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] = "";
+ builder.AddServiceDefaults();
+
+ await using var app = builder.Build();
+
+ Assert.NotNull(app.Services.GetRequiredService());
+ Assert.NotNull(app.Services.GetRequiredService());
+ Assert.NotNull(app.Services.GetRequiredService());
+ }
+}
diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj b/src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj
new file mode 100644
index 00000000..044872af
--- /dev/null
+++ b/src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs b/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs
new file mode 100644
index 00000000..f66e569a
--- /dev/null
+++ b/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs
@@ -0,0 +1,118 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+namespace DigitalBrain.ServiceDefaults;
+
+public static class ServiceDefaultsExtensions
+{
+ private const string AlivePath = "/alive";
+ private const string HealthPath = "/health";
+ private const string TelemetryPrefix = "DigitalBrain";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ ConfigureOpenTelemetry(builder);
+ AddDefaultHealthChecks(builder);
+
+ builder.Services.AddServiceDiscovery();
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ http.AddStandardResilienceHandler();
+ http.AddServiceDiscovery();
+ });
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+
+ app.MapHealthChecks(HealthPath);
+ app.MapHealthChecks(AlivePath, new HealthCheckOptions
+ {
+ Predicate = static registration =>
+ registration.Tags.Contains("live"),
+ });
+
+ return app;
+ }
+
+ private static void AddDefaultHealthChecks(TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ => builder.Services
+ .AddHealthChecks()
+ .AddCheck(
+ "self",
+ static () => HealthCheckResult.Healthy(),
+ ["live"]);
+
+ private static void AddOpenTelemetryExporters(TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ if (!string.IsNullOrWhiteSpace(
+ builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]))
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+ }
+
+ private static void ConfigureOpenTelemetry(TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ var configuredSampleRatio = builder.Configuration.GetValue(
+ "Telemetry:Tracing:SampleRatio");
+ var sampleRatio = Math.Clamp(configuredSampleRatio ?? 1d, 0d, 1d);
+
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .ConfigureResource(resource => resource.AddService(
+ serviceName: builder.Environment.ApplicationName,
+ serviceNamespace: TelemetryPrefix))
+ .WithMetrics(metrics => metrics
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation()
+ .AddMeter(TelemetryPrefix)
+ .AddMeter($"{TelemetryPrefix}.*")
+ .AddMeter("Experimental.Microsoft.Extensions.AI")
+ .AddMeter("Microsoft.Orleans"))
+ .WithTracing(tracing => tracing
+ .SetSampler(new ParentBasedSampler(
+ new TraceIdRatioBasedSampler(sampleRatio)))
+ .AddSource(builder.Environment.ApplicationName)
+ .AddSource(TelemetryPrefix)
+ .AddSource($"{TelemetryPrefix}.*")
+ .AddSource("Experimental.Microsoft.Extensions.AI")
+ .AddSource("Microsoft.Orleans.Application")
+ .AddAspNetCoreInstrumentation(options =>
+ options.Filter = context =>
+ !context.Request.Path.StartsWithSegments(
+ HealthPath,
+ StringComparison.OrdinalIgnoreCase)
+ && !context.Request.Path.StartsWithSegments(
+ AlivePath,
+ StringComparison.OrdinalIgnoreCase))
+ .AddHttpClientInstrumentation());
+
+ AddOpenTelemetryExporters(builder);
+ }
+}
diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs
index 3a28d9d7..a74582fc 100644
--- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs
+++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs
@@ -55,8 +55,10 @@ public void SupportedOllamaModelResolvesWithExpectedDefaultTag(
using var provider = Compose(endpoint);
var client = provider.GetRequiredKeyedService(modelType);
- var ollama = Assert.IsAssignableFrom(client);
+ var ollama = Assert.IsAssignableFrom(
+ client.GetService(typeof(IOllamaApiClient)));
+ Assert.NotSame(ollama, client);
Assert.Equal(new Uri(endpoint), ollama.Uri);
Assert.False(string.Equals("localhost", ollama.Uri.Host, StringComparison.OrdinalIgnoreCase));
Assert.Equal(expectedTag, ollama.SelectedModel);
diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs
index 7c0464e2..164caa1c 100644
--- a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs
+++ b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs
@@ -1,4 +1,5 @@
using System.ClientModel;
+using System.Diagnostics.CodeAnalysis;
using DigitalBrain.AI.Ollama;
using DigitalBrain.AI.OpenAI;
using Microsoft.Extensions.AI;
@@ -12,6 +13,7 @@ namespace DigitalBrain.AI;
internal static class AIClients
{
private const string ConfigurationRoot = "DigitalBrain:AI";
+ private const string TelemetrySource = "DigitalBrain.AI";
internal static void Add(IServiceCollection services)
{
@@ -34,7 +36,11 @@ private static void AddOllamaModel(IServiceCollection services, string d
typeof(TModel).Name,
defaultTag));
- private static OllamaApiClient Ollama(
+ [SuppressMessage(
+ "Reliability",
+ "CA2000:Dispose objects before losing scope",
+ Justification = "The telemetry middleware owns and disposes the inner Ollama client.")]
+ private static IChatClient Ollama(
IConfiguration configuration,
string modelName,
string defaultTag)
@@ -51,7 +57,9 @@ private static OllamaApiClient Ollama(
var tag = configuration[$"{ConfigurationRoot}:Ollama:{modelName}:Model"] ?? defaultTag;
- return new OllamaApiClient(endpointUri, tag);
+ return new ChatClientBuilder(new OllamaApiClient(endpointUri, tag))
+ .UseOpenTelemetry(sourceName: $"{TelemetrySource}.{modelName}")
+ .Build();
}
private static IChatClient OpenAI(IConfiguration configuration)
@@ -67,8 +75,12 @@ private static IChatClient OpenAI(IConfiguration configuration)
options.Endpoint = new Uri(endpoint, UriKind.Absolute);
}
- return new OpenAIClient(new ApiKeyCredential(apiKey), options)
+ var client = new OpenAIClient(new ApiKeyCredential(apiKey), options)
.GetChatClient(model)
.AsIChatClient();
+
+ return new ChatClientBuilder(client)
+ .UseOpenTelemetry(sourceName: $"{TelemetrySource}.{nameof(Gpt56)}")
+ .Build();
}
}
diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs
index 5e23d638..fcc1e8ab 100644
--- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs
+++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs
@@ -31,7 +31,6 @@ public void PublicEdgeVocabularyIsUIEdgeContract()
or "IFlutter");
Assert.Equal("/health", UIEdgeContract.HealthPath);
- Assert.Equal("healthy", UIEdgeContract.HealthResponse);
Assert.Equal("/shells/{shellName}/scenes", UIEdgeContract.OpenScenePath);
Assert.Equal("/shells/{shellName}/events", UIEdgeContract.ShellEventsPath);
Assert.Equal(
@@ -103,8 +102,5 @@ public async Task MapUIHostServesHealth()
cancellationToken);
Assert.Equal(HttpStatusCode.OK, health.StatusCode);
- Assert.Equal(
- $"\"{UIEdgeContract.HealthResponse}\"",
- (await health.Content.ReadAsStringAsync(cancellationToken)).Trim());
}
}
diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs
index 5f10e8b3..5841adc0 100644
--- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs
+++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs
@@ -1,5 +1,6 @@
using DigitalBrain.Flutter;
using DigitalBrain.Flutter.Aspire.Hosting;
+using DigitalBrain.ServiceDefaults;
using DigitalBrain.Testing;
namespace DigitalBrain.UI.Tests;
@@ -31,11 +32,13 @@ public static async Task StartUIEdgeAsync(
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
+ builder.AddServiceDefaults();
builder.Services.AddSingleton(test.Client);
builder.Services.AddSingleton(test.Cluster.Client);
builder.Services.AddUIEdgeServices();
var app = builder.Build();
+ app.MapDefaultEndpoints();
app.MapUIHost();
await app.StartAsync(cancellationToken);
return app;
diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj
index 3ed4a5fc..ee17f53a 100644
--- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj
+++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs
index c658e4e8..df3c272b 100644
--- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs
+++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs
@@ -1,12 +1,15 @@
using DigitalBrain.Aspire;
+using DigitalBrain.ServiceDefaults;
using DigitalBrain.UI;
var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
builder.AddKeyedAzureTableServiceClient("brain-clustering");
builder.AddDigitalBrainClient();
builder.Services.AddUIEdgeServices();
var app = builder.Build();
+app.MapDefaultEndpoints();
app.MapUIHost();
app.Run();
diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs
index c31a3aa5..46988995 100644
--- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs
+++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs
@@ -10,8 +10,6 @@ public static class UIEdgeContract
{
public const string HealthPath = "/health";
- public const string HealthResponse = "healthy";
-
public const string OpenScenePath = "/shells/{shellName}/scenes";
public const string ShellEventsPath = "/shells/{shellName}/events";
diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs
index a818c535..7a98d7de 100644
--- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs
+++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs
@@ -6,7 +6,6 @@ public static WebApplication MapUIHost(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
- app.MapGet(UIEdgeContract.HealthPath, static () => Results.Ok(UIEdgeContract.HealthResponse));
app.MapUI();
app.MapChat();
return app;
From 86e77229bbdb11b45bb33611a77bc911ede3a617 Mon Sep 17 00:00:00 2001
From: Vladyslav Horbachov <0965142838gvs@gmail.com>
Date: Tue, 28 Jul 2026 07:12:58 +0200
Subject: [PATCH 03/20] feat: make assistant product-aware
---
os/DigitalBrain.OS.Behaviors/Assistant.cs | 39 ++++++++++++++++++-
.../Features/Chat.feature | 8 ++++
.../Steps/ChatSteps.cs | 14 +++++++
.../Edges/ScriptedChatClient.cs | 15 +++++++
.../CapabilityToolProbes.cs | 4 ++
.../CapabilityToolSeam.cs | 26 +++++++++++++
.../ai/DigitalBrain.Modules.AI/Agent.cs | 8 +++-
7 files changed, 111 insertions(+), 3 deletions(-)
diff --git a/os/DigitalBrain.OS.Behaviors/Assistant.cs b/os/DigitalBrain.OS.Behaviors/Assistant.cs
index 085ee139..f7d83452 100644
--- a/os/DigitalBrain.OS.Behaviors/Assistant.cs
+++ b/os/DigitalBrain.OS.Behaviors/Assistant.cs
@@ -17,12 +17,30 @@ internal sealed class Assistant(
public const string DefaultGmailAccount = "gmail";
public const string DefaultSalesforceAccount = "salesforce";
+ protected override string? Instructions =>
+ """
+ You are DigitalBrain, the AI assistant inside the DigitalBrain personal agent system.
+ Be precise about your identity and never invent tools, integrations, or completed actions.
+
+ You can provide general conversational help using the current chat context. In this
+ deployment you have one external action: enrich_account_from_email. Given an exact Gmail
+ message ID and a 15- or 18-character Salesforce Account ID, it reads that specific Gmail
+ message and creates a durable proposal to replace that Account's Description. The proposal
+ is not written to Salesforce until the owner separately gives explicit approval.
+
+ You cannot search or list Gmail, send email, directly update Salesforce, approve a proposal,
+ or create accounts, contacts, or leads. Ask for both required IDs when either is missing.
+ Do not call the tool merely to describe your capabilities. Never describe a proposal as a
+ completed update. If authentication or a provider is unavailable, report that limitation.
+ """;
+
protected override IReadOnlyList Tools =>
[
Capability(
EnrichAccountFromEmail,
- "Read a Gmail message and propose it as the description of a Salesforce account. "
- + "The proposal always waits for the owner's approval before anything is written.",
+ "Given an exact Gmail message ID and a 15- or 18-character Salesforce Account ID, "
+ + "read only that message and create an approval-gated Account Description proposal. "
+ + "This cannot search Gmail or directly write Salesforce.",
([Description("Salesforce account id, 15 or 18 characters")] string accountId,
[Description("Gmail message id to read")] string messageId)
=> ProposeEnrichmentAsync(accountId, messageId)),
@@ -30,6 +48,9 @@ internal sealed class Assistant(
private async Task ProposeEnrichmentAsync(string accountId, string messageId)
{
+ ValidateAccountId(accountId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
+
var message = await Gmail().ReadMessage(messageId, CancellationToken.None);
var mutation = await Salesforce().ProposeAccountDescription(
CommandId.New(),
@@ -42,6 +63,20 @@ private async Task ProposeEnrichmentAsync(string accountId, string messa
+ $"It is {mutation.State} and will not be written until you approve it.";
}
+ private static void ValidateAccountId(string accountId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(accountId);
+
+ if (accountId.Length is not (15 or 18)
+ || accountId.Any(static character =>
+ !char.IsAsciiLetterOrDigit(character)))
+ {
+ throw new ArgumentException(
+ "A Salesforce Account ID must be a 15- or 18-character alphanumeric value.",
+ nameof(accountId));
+ }
+ }
+
private IGmail Gmail()
=> GrainFactory.GetGrain(
NeuronId.For(Id.Owner, DefaultGmailAccount).ToGrainId());
diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature b/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature
index 49bb229a..d4bc6f22 100644
--- a/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature
+++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature
@@ -11,6 +11,14 @@ Feature: The operating system answers through a behaviour
Then the conversation "main" journals the user message "how is my account?"
And the conversation "main" journals the assistant reply "Your account is up to date."
+ Scenario: a greeting stays conversational
+ Given a DigitalBrain for the default owner
+ And the conversation "main" is observed
+ And the assistant will reply "Hello! How can I help?"
+ When the owner sends "hi" to the conversation "main"
+ Then the conversation "main" journals the assistant reply "Hello! How can I help?"
+ And the assistant selects no external capability
+
Scenario: the conversation remembers earlier turns
Given a DigitalBrain for the default owner
And the conversation "main" is observed
diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs
index 4cdf102d..c59ad852 100644
--- a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs
+++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs
@@ -1,4 +1,5 @@
using DigitalBrain.Abstractions;
+using DigitalBrain.AI;
using DigitalBrain.Chat;
using DigitalBrain.Testing;
using Reqnroll;
@@ -46,4 +47,17 @@ public async Task ThenTheTranscriptHasTurns(string conversation, int expected)
Assert.Equal(expected, transcript.Turns.Count);
}
+
+ [Then("the assistant selects no external capability")]
+ public async Task ThenTheAssistantSelectsNoExternalCapability()
+ {
+ var selected = await world
+ .Neuron(ChatResponder.AssistantName)
+ .Outgoing
+ .ReadAsync(
+ afterSequence: 0,
+ world.CancellationToken);
+
+ Assert.Empty(selected);
+ }
}
diff --git a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs
index b1701b1a..d0d0ff18 100644
--- a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs
+++ b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs
@@ -8,6 +8,7 @@ public sealed class ScriptedChatClient : IChatClient
private readonly Lock _gate = new();
private readonly Queue _replies = [];
private int _callCount;
+ private ChatMessage[] _lastMessages = [];
public int CallCount
{
@@ -20,6 +21,17 @@ public int CallCount
}
}
+ public IReadOnlyList LastMessages
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return [.. _lastMessages];
+ }
+ }
+ }
+
public void Reply(string text)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
@@ -41,6 +53,7 @@ public void Reset()
lock (_gate)
{
_callCount = 0;
+ _lastMessages = [];
_replies.Clear();
}
}
@@ -90,9 +103,11 @@ private ChatResponse NextResponse(
cancellationToken.ThrowIfCancellationRequested();
ChatMessage reply;
+ var request = messages.ToArray();
lock (_gate)
{
_callCount++;
+ _lastMessages = request;
reply = _replies.Count > 0
? _replies.Dequeue()
: throw new InvalidOperationException(
diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs
index 972e5ad1..62249874 100644
--- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs
+++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs
@@ -41,6 +41,10 @@ public sealed class ToolAgentProbe(
{
public const string EnrichTool = "enrich_account_from_email";
public const string ProbeName = "enrichment";
+ public const string ProbeInstructions =
+ "You are the capability seam probe. Use only the supplied tools.";
+
+ protected override string? Instructions => ProbeInstructions;
protected override IReadOnlyList Tools =>
[
diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs
index f77f7b2b..cc2e596a 100644
--- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs
+++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs
@@ -10,8 +10,34 @@ public sealed class CapabilityToolSeam(ModuleFixture fixture)
private const string AccountId = "001AAAAAAAAAAAAAAA";
private const string MessageId = "msg-42";
private const string FinalReply = "Account updated.";
+ private const string IdentityPrompt = "Who are you?";
private const int SeamTimeout = 60_000;
+ [Fact(Timeout = SeamTimeout, DisplayName =
+ "agent instructions reach the model as the first system turn")]
+ public async Task AgentInstructionsReachModelAsFirstSystemTurn()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var test = await fixture.CreateBrainAsync(cancellationToken);
+ test.Chat().Reply(FinalReply);
+
+ await test.Client.Get(AgentName).Respond(
+ [new(Microsoft.Extensions.AI.ChatRole.User, IdentityPrompt)]);
+
+ Assert.Collection(
+ test.Chat().LastMessages,
+ system =>
+ {
+ Assert.Equal(Microsoft.Extensions.AI.ChatRole.System, system.Role);
+ Assert.Equal(ToolAgentProbe.ProbeInstructions, system.Text);
+ },
+ user =>
+ {
+ Assert.Equal(Microsoft.Extensions.AI.ChatRole.User, user.Role);
+ Assert.Equal(IdentityPrompt, user.Text);
+ });
+ }
+
[Fact(Timeout = SeamTimeout, DisplayName =
"a model tool call reaches the real neuron capability with the model's own arguments")]
public async Task ModelToolCallInvokesNeuronCapability()
diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs b/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs
index d0b0410b..7f23edc9 100644
--- a/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs
+++ b/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs
@@ -18,6 +18,8 @@ protected Agent(IChatClient chatClient)
protected abstract IReadOnlyList Tools { get; }
+ protected virtual string? Instructions => null;
+
protected static CapabilityTool Capability(string name, string description, Delegate invoke)
=> new(name, description, invoke);
@@ -31,8 +33,12 @@ public async Task Respond(IReadOnlyList messages)
Tools = [.. Tools.Select(tool => tool.BindTo(turnScheduler))],
ToolMode = ChatToolMode.Auto,
};
+ var instructions = Instructions;
+ IReadOnlyList request = string.IsNullOrWhiteSpace(instructions)
+ ? messages
+ : [new ChatMessage(ChatRole.System, instructions), .. messages];
- var response = await _toolCallingClient.GetResponseAsync(messages, options);
+ var response = await _toolCallingClient.GetResponseAsync(request, options);
foreach (var selected in SelectedCapabilities(response))
{
From 811c470563cfe9f65a37b07c93cd426d833d8eb2 Mon Sep 17 00:00:00 2001
From: Vladyslav Horbachov <0965142838gvs@gmail.com>
Date: Tue, 28 Jul 2026 07:25:12 +0200
Subject: [PATCH 04/20] feat: close the MCP chat loop
---
DigitalBrain.slnx | 1 +
os/DigitalBrain.OS.AppHost/AppHost.cs | 7 +-
.../ProductSurfaceResources.cs | 1 +
os/DigitalBrain.OS.Mcp/AssemblyInfo.cs | 3 +
.../DigitalBrain.OS.Mcp.csproj | 1 -
.../DigitalBrainMcpTools.cs | 110 ++++++++++++++++--
os/DigitalBrain.OS.Mcp/McpHost.cs | 3 +-
os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs | 8 ++
os/DigitalBrain.OS.Mcp/Program.cs | 8 +-
.../DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs | 4 +
.../ChatProductTool.cs | 47 ++++++++
.../DigitalBrain.OS.Mcp.Tests.csproj | 20 ++++
.../DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs | 19 +++
.../Neurons/INeuron.cs | 2 +
.../Neurons/ISessionNeuron.cs | 1 +
.../Neurons/NeuronCallTimeouts.cs | 6 +
.../LongRunningNeuronContracts.cs | 24 ++++
.../DigitalBrain.Testing/AssemblyInfo.cs | 1 +
.../IAgent.cs | 1 +
.../AgentResponseTimeoutContracts.cs | 18 +++
20 files changed, 264 insertions(+), 21 deletions(-)
create mode 100644 os/DigitalBrain.OS.Mcp/AssemblyInfo.cs
create mode 100644 os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs
create mode 100644 os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs
create mode 100644 os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj
create mode 100644 os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs
create mode 100644 src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs
create mode 100644 src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs
create mode 100644 src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs
diff --git a/DigitalBrain.slnx b/DigitalBrain.slnx
index c186efa5..ca9913aa 100644
--- a/DigitalBrain.slnx
+++ b/DigitalBrain.slnx
@@ -56,6 +56,7 @@
+
diff --git a/os/DigitalBrain.OS.AppHost/AppHost.cs b/os/DigitalBrain.OS.AppHost/AppHost.cs
index d291a24c..adb77792 100644
--- a/os/DigitalBrain.OS.AppHost/AppHost.cs
+++ b/os/DigitalBrain.OS.AppHost/AppHost.cs
@@ -28,6 +28,7 @@
var silo = builder.AddProject(ProductSurfaceResources.Silo)
.WithReference(brain);
+#pragma warning disable ASPIREMCP001
builder.AddProject(ProductSurfaceResources.Mcp)
.WithReference(brain.AsClient())
.WaitFor(silo)
@@ -37,6 +38,10 @@
.WithHttpEndpoint(
port: ProductSurfaceResources.McpHttpPort,
name: ProductSurfaceResources.McpHttpEndpointName,
- isProxied: false);
+ isProxied: false)
+ .WithMcpServer(
+ ProductSurfaceResources.McpPath,
+ ProductSurfaceResources.McpHttpEndpointName);
+#pragma warning restore ASPIREMCP001
builder.Build().Run();
diff --git a/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs b/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs
index 058fe4d7..95515de2 100644
--- a/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs
+++ b/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs
@@ -4,5 +4,6 @@ internal static class ProductSurfaceResources
public const string Silo = "silo";
public const string Mcp = "digitalbrain-mcp";
public const string McpHttpEndpointName = "http";
+ public const string McpPath = "/mcp";
public const int McpHttpPort = 5000;
}
diff --git a/os/DigitalBrain.OS.Mcp/AssemblyInfo.cs b/os/DigitalBrain.OS.Mcp/AssemblyInfo.cs
new file mode 100644
index 00000000..57d16b52
--- /dev/null
+++ b/os/DigitalBrain.OS.Mcp/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("DigitalBrain.OS.Mcp.Tests")]
diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj
index 08c22eca..0b0b6bb6 100644
--- a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj
+++ b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj
@@ -9,7 +9,6 @@
-
diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs
index fcd7f6bb..759e6d3b 100644
--- a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs
+++ b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs
@@ -1,8 +1,8 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
-using DigitalBrain.AI.Ollama;
+using DigitalBrain.Abstractions;
+using DigitalBrain.Chat;
using DigitalBrain.Client;
-using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;
namespace DigitalBrain.OS.Mcp;
@@ -12,16 +12,106 @@ namespace DigitalBrain.OS.Mcp;
"Performance",
"CA1812:Avoid uninstantiated internal classes",
Justification = "Constructed by the MCP server DI container via WithTools().")]
-internal sealed class DigitalBrainMcpTools(IDigitalBrain brain)
+internal sealed class DigitalBrainMcpTools(IDigitalBrain brain, IGrainFactory grains)
{
- [McpServerTool(Name = McpHost.AskLlama32ToolName)]
- [Description("Ask the owner's Llama 3.2 neuron and return its ChatResponse.")]
- public Task AskLlama32Async(
- [Description("User prompt for Llama 3.2")] string prompt)
+ private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
+ private const int DefaultTimeoutSeconds = 120;
+ private const int MaximumTimeoutSeconds = 180;
+
+ [McpServerTool(Name = McpHost.SendChatMessageToolName)]
+ [Description(
+ "Send a message through the owner's DigitalBrain conversation and wait for the exact "
+ + "correlated assistant response. This is the same durable chat path used by the product UI.")]
+ public async Task SendChatMessageAsync(
+ [Description("Message to send to DigitalBrain")] string text,
+ [Description("Conversation name, for example 'main'")] string chatName = "main",
+ [Description("Maximum wait in seconds, from 1 through 180")]
+ int timeoutSeconds = DefaultTimeoutSeconds,
+ CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(prompt);
+ ArgumentException.ThrowIfNullOrWhiteSpace(text);
+ ArgumentException.ThrowIfNullOrWhiteSpace(chatName);
+ ArgumentOutOfRangeException.ThrowIfLessThan(timeoutSeconds, 1);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(
+ timeoutSeconds,
+ MaximumTimeoutSeconds);
+
+ var chatId = NeuronId.For(brain.Owner, chatName);
+ var session = grains.GetGrain(
+ ISessionNeuron.ForOwner(brain.Owner).ToGrainId());
+ var baseline = await session.ReadNeuronJournal(
+ chatId,
+ JournalKind.Outgoing,
+ afterSequence: 0);
+ var commandId = CommandId.New();
+
+ await brain.Get(chatName).Send(new SendMessage(commandId, text));
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
+
+ try
+ {
+ return await WaitForResponseAsync(
+ session,
+ chatId,
+ chatName,
+ commandId,
+ baseline.ResumeSequence,
+ timeout.Token);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException(
+ $"DigitalBrain did not answer command '{commandId}' in conversation "
+ + $"'{chatName}' within {timeoutSeconds} seconds.");
+ }
+ }
+
+ private static async Task WaitForResponseAsync(
+ ISessionNeuron session,
+ NeuronId chatId,
+ string chatName,
+ CommandId commandId,
+ long afterSequence,
+ CancellationToken cancellationToken)
+ {
+ var cursor = afterSequence;
+ CorrelationId? correlation = null;
+
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var page = await session.ReadNeuronJournal(
+ chatId,
+ JournalKind.Outgoing,
+ cursor);
+
+ foreach (var delivery in page.Delta)
+ {
+ if (delivery.Synapse is UserMessaged messaged
+ && messaged.CommandId == commandId)
+ {
+ correlation = delivery.CorrelationId;
+ }
+
+ if (delivery.Synapse is AssistantResponded response
+ && correlation is { } expected
+ && delivery.CorrelationId == expected)
+ {
+ return new ChatMessageResult(
+ chatName,
+ commandId.ToString(),
+ expected.Value.ToString("N"),
+ response.Text,
+ delivery.Sequence,
+ delivery.Timestamp);
+ }
+ }
- return brain.Get(McpHost.DefaultLlama32Key).Respond(
- [new ChatMessage(ChatRole.User, prompt)]);
+ cursor = page.ResumeSequence;
+ await Task.Delay(PollInterval, cancellationToken);
+ }
}
}
diff --git a/os/DigitalBrain.OS.Mcp/McpHost.cs b/os/DigitalBrain.OS.Mcp/McpHost.cs
index 28e8c3c0..5e35e373 100644
--- a/os/DigitalBrain.OS.Mcp/McpHost.cs
+++ b/os/DigitalBrain.OS.Mcp/McpHost.cs
@@ -3,8 +3,7 @@ namespace DigitalBrain.OS.Mcp;
internal static class McpHost
{
public const string EndpointPath = "/mcp";
- public const string AskLlama32ToolName = "ask_llama32";
- public const string DefaultLlama32Key = "default";
+ public const string SendChatMessageToolName = "send_chat_message";
public const string ListActiveNeuronsToolName = "list_active_neurons";
public const string ReadNeuronJournalToolName = "read_neuron_journal";
public const string ReadChatTranscriptToolName = "read_chat_transcript";
diff --git a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs
index 145d955a..4cfe32d5 100644
--- a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs
+++ b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs
@@ -21,3 +21,11 @@ internal sealed record ChatTranscriptPage(
IReadOnlyList Turns);
internal sealed record ChatTranscriptTurn(string Speaker, string Text);
+
+internal sealed record ChatMessageResult(
+ string Chat,
+ string CommandId,
+ string CorrelationId,
+ string Response,
+ long Sequence,
+ DateTimeOffset Timestamp);
diff --git a/os/DigitalBrain.OS.Mcp/Program.cs b/os/DigitalBrain.OS.Mcp/Program.cs
index 1c5f542a..20a93d97 100644
--- a/os/DigitalBrain.OS.Mcp/Program.cs
+++ b/os/DigitalBrain.OS.Mcp/Program.cs
@@ -1,18 +1,12 @@
using DigitalBrain.Aspire;
using DigitalBrain.OS.Mcp;
using DigitalBrain.ServiceDefaults;
-using Microsoft.Extensions.AI;
-using Orleans.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddKeyedAzureTableServiceClient("brain-clustering");
-builder.AddDigitalBrainClient(client =>
-{
- client.Services.AddSerializer(serializer => serializer.AddJsonSerializer(
- static type => type == typeof(ChatMessage) || type == typeof(ChatResponse)));
-});
+builder.AddDigitalBrainClient();
builder.Services
.AddMcpServer()
diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs
new file mode 100644
index 00000000..afab76ff
--- /dev/null
+++ b/os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using DigitalBrain.OS.Mcp.Tests;
+using Xunit;
+
+[assembly: AssemblyFixture(typeof(OSMcpFixture))]
diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs
new file mode 100644
index 00000000..9b370980
--- /dev/null
+++ b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs
@@ -0,0 +1,47 @@
+using DigitalBrain.Chat;
+using DigitalBrain.Testing;
+using Xunit;
+
+namespace DigitalBrain.OS.Mcp.Tests;
+
+public sealed class ChatProductTool(OSMcpFixture fixture)
+{
+ private const int TestTimeout = 60_000;
+
+ [Fact(
+ Timeout = TestTimeout,
+ DisplayName = "send_chat_message returns the correlated assistant response")]
+ public async Task SendChatMessageReturnsCorrelatedAssistantResponse()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var test = await fixture.CreateBrainAsync(cancellationToken);
+ test.Chat().Reply("Hello from DigitalBrain.");
+ var tools = new DigitalBrainMcpTools(test.Client, test.Cluster.Client);
+
+ var result = await tools.SendChatMessageAsync(
+ "hi",
+ "main",
+ timeoutSeconds: 10,
+ cancellationToken);
+
+ Assert.Equal("main", result.Chat);
+ Assert.Equal("Hello from DigitalBrain.", result.Response);
+ Assert.NotEqual(Guid.Empty.ToString("N"), result.CommandId);
+ Assert.NotEqual(Guid.Empty.ToString("N"), result.CorrelationId);
+ Assert.True(result.Sequence > 0);
+
+ var transcript = await test.Client.Get("main").Read();
+ Assert.Collection(
+ transcript.Turns,
+ user =>
+ {
+ Assert.True(user.FromUser);
+ Assert.Equal("hi", user.Text);
+ },
+ assistant =>
+ {
+ Assert.False(assistant.FromUser);
+ Assert.Equal(result.Response, assistant.Text);
+ });
+ }
+}
diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj b/os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj
new file mode 100644
index 00000000..5167e537
--- /dev/null
+++ b/os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+ Exe
+ $(NoWarn);CA2007;CA1515
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs
new file mode 100644
index 00000000..86fcbebc
--- /dev/null
+++ b/os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs
@@ -0,0 +1,19 @@
+using DigitalBrain.AI;
+using DigitalBrain.AI.Ollama;
+using DigitalBrain.Chat;
+using DigitalBrain.OS;
+using DigitalBrain.Testing;
+
+namespace DigitalBrain.OS.Mcp.Tests;
+
+public sealed class OSMcpFixture : DigitalBrainFixture
+{
+ protected override void Configure(DigitalBrainTestBuilder brain)
+ {
+ ArgumentNullException.ThrowIfNull(brain);
+ brain.AddModule();
+ brain.AddModule();
+ brain.AddModule();
+ brain.ConfigureScriptedChat(typeof(Gemma4));
+ }
+}
diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs
index eac309b6..1afc542c 100644
--- a/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs
+++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs
@@ -3,9 +3,11 @@ namespace DigitalBrain.Abstractions;
public partial interface INeuron : IGrainWithStringKey
{
[Alias(nameof(Deliver))]
+ [ResponseTimeout(NeuronCallTimeouts.LongRunning)]
Task Deliver(SynapseDelivery delivery);
[Alias(nameof(ReadJournal))]
+ [ResponseTimeout(NeuronCallTimeouts.LongRunning)]
Task ReadJournal(JournalKind kind, long afterSequence);
[Alias(nameof(Watch))]
diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs
index 22ce4d60..3c728344 100644
--- a/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs
+++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs
@@ -20,6 +20,7 @@ static NeuronId ForOwner(OwnerId owner)
Task Emit(Synapse synapse);
[Alias(nameof(ReadNeuronJournal))]
+ [ResponseTimeout(NeuronCallTimeouts.LongRunning)]
Task ReadNeuronJournal(NeuronId subject, JournalKind kind, long afterSequence);
[Alias(nameof(WatchNeuron))]
diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs
new file mode 100644
index 00000000..e883cf23
--- /dev/null
+++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs
@@ -0,0 +1,6 @@
+namespace DigitalBrain.Abstractions;
+
+public static class NeuronCallTimeouts
+{
+ public const string LongRunning = "00:05:00";
+}
diff --git a/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs
new file mode 100644
index 00000000..542316cc
--- /dev/null
+++ b/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs
@@ -0,0 +1,24 @@
+using System.Reflection;
+using DigitalBrain.Abstractions;
+using Xunit;
+
+namespace DigitalBrain.Testing.Tests;
+
+public sealed class LongRunningNeuronContracts
+{
+ [Theory]
+ [InlineData(typeof(INeuron), nameof(INeuron.Deliver))]
+ [InlineData(typeof(INeuron), nameof(INeuron.ReadJournal))]
+ [InlineData(typeof(ISessionNeuron), nameof(ISessionNeuron.ReadNeuronJournal))]
+ public void LongRunningNeuronCallsDeclareProductTimeout(
+ Type contract,
+ string methodName)
+ {
+ ArgumentNullException.ThrowIfNull(contract);
+ var method = contract.GetMethod(methodName);
+ var timeout = method?.GetCustomAttribute();
+
+ Assert.NotNull(timeout);
+ Assert.Equal(TimeSpan.FromMinutes(5), timeout.Timeout);
+ }
+}
diff --git a/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs b/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs
index cd281df6..d4101c09 100644
--- a/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs
+++ b/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs
@@ -2,3 +2,4 @@
[assembly: InternalsVisibleTo("DigitalBrain.Mcp.Tests")]
[assembly: InternalsVisibleTo("DigitalBrain.Modules.Flutter.Http.Tests")]
+[assembly: InternalsVisibleTo("DigitalBrain.OS.Mcp.Tests")]
diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs
index d34bb21f..bb939e42 100644
--- a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs
+++ b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs
@@ -7,5 +7,6 @@ namespace DigitalBrain.AI;
public partial interface IAgent : INeuron
{
[Alias(nameof(Respond))]
+ [ResponseTimeout(NeuronCallTimeouts.LongRunning)]
Task Respond(IReadOnlyList messages);
}
diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs
new file mode 100644
index 00000000..b19537cb
--- /dev/null
+++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs
@@ -0,0 +1,18 @@
+using System.Reflection;
+using Xunit;
+
+namespace DigitalBrain.ModuleTests;
+
+public sealed class AgentResponseTimeoutContracts
+{
+ [Fact]
+ public void AgentResponsesDeclareProductTimeout()
+ {
+ var method = typeof(DigitalBrain.AI.IAgent)
+ .GetMethod(nameof(DigitalBrain.AI.IAgent.Respond));
+ var timeout = method?.GetCustomAttribute();
+
+ Assert.NotNull(timeout);
+ Assert.Equal(TimeSpan.FromMinutes(5), timeout.Timeout);
+ }
+}
From 7e6460199b2c43332528118dc170a4a48ecd25ad Mon Sep 17 00:00:00 2001
From: Vladyslav Horbachov <0965142838gvs@gmail.com>
Date: Tue, 28 Jul 2026 07:38:48 +0200
Subject: [PATCH 05/20] feat: automate live product verification
Adds a self-cleaning Aspire proof for resource health, the exact MCP catalog, a real Gemma4 turn, durable transcript correlation, and telemetry hygiene. Updates the repository status to the measured observable product state.\n\nNo unconsumed product surface was added; the verifier is linked from the contributor workflow. All capability claims were checked by the script's live successful run. The three pre-existing Flutter generated-file changes remain unstaged and untouched.
---
CLAUDE.md | 18 +--
README.md | 15 ++-
scripts/verify-product.ps1 | 269 +++++++++++++++++++++++++++++++++++++
3 files changed, 290 insertions(+), 12 deletions(-)
create mode 100644 scripts/verify-product.ps1
diff --git a/CLAUDE.md b/CLAUDE.md
index 0ab2e935..a0241e77 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,7 +65,7 @@ the live system. Tests are the regression net, not the proof.
3. Drive the real scenario through the real edge — HTTP to `digitalbrain-ui`, or the Flutter shell.
4. **Read the journal** via `digitalbrain-mcp`: `read_neuron_journal`, `read_chat_transcript`,
`list_active_neurons`. Confirm the expected synapses fired, one correlation id, right order.
-5. Cross-check Aspire — but only for what the table below says works.
+5. Cross-check Aspire against structured logs and spans.
6. Root gate for regression.
7. Only now claim, quoting what you saw.
@@ -78,20 +78,20 @@ causal facts only — never arguments, prompts or secrets. Telemetry tags follow
|---|---|
| `list_resources`, `list_console_logs` | works — console output is noisy with Azurite spam |
| `digitalbrain-mcp` journals | works, and is authoritative |
-| `list_structured_logs` | **empty** |
-| `list_traces` | **no application spans** — `dotnet-cli` only |
-| GenAI spans, metrics | **do not exist** |
+| Structured logs | works — MCP invocation completion includes tool name and error state |
+| Application traces | works — ASP.NET, Orleans and kernel spans carry causal identifiers |
+| GenAI spans, metrics | works — provider, model, duration, token usage and finish reason; no prompt or response content |
-No host calls `ConfigureOpenTelemetry`, no chat client calls `UseOpenTelemetry`, and the kernel's
-`ActivitySource("DigitalBrain")` has no exporter. **This is the top open defect.** Until it lands,
-step 5 confirms health and console output only — say that, rather than implying traces were checked.
-Update this table when you fix it.
+`scripts/verify-product.ps1` is the one-command live oracle. It builds Release, waits for the product
+resources, checks the exact MCP catalog, drives a real Gemma4 turn, confirms the durable transcript
+and correlation, checks telemetry content hygiene, and stops the AppHost unless `-KeepRunning` is
+specified.
## Gates
```powershell
dotnet build DigitalBrain.slnx -c Release
-dotnet test DigitalBrain.slnx -c Release --logger "console;verbosity=minimal"
+dotnet test DigitalBrain.slnx -c Release
```
**Never `--filter` for the completion gate** — a project-scoped run has already missed a failing
diff --git a/README.md b/README.md
index 7a4e1fed..d02819c5 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ await brain.SendAsync(
```
The owner-bound `IDigitalBrain` facade enters through a session; neurons call typed capabilities such
-as `ILlama32` inside the brain. The same vocabulary will later carry approved C# behaviors generated
+as `IGemma4` inside the brain. The same vocabulary will later carry approved C# behaviors generated
from natural language.
## The shape of it
@@ -22,7 +22,7 @@ from natural language.
- **A synapse is a fact** — a thin record, broadcast, no reply. **An interface method is a request** —
directed at a capability, and it replies. Both are journaled; neither is privileged.
- **Modules own vocabulary** — synapse records and neuron interfaces, resolved at compile time.
-- **Namespaces and type names are architecture** — `DigitalBrain.AI.Ollama.ILlama32` is identity, not
+- **Namespaces and type names are architecture** — `DigitalBrain.AI.Ollama.IGemma4` is identity, not
a lookup result.
- **Journals are the audit source**, recording causal facts only — never arguments, prompts or
secrets. Telemetry is a projection and never replaces them.
@@ -37,11 +37,12 @@ The plan of record. Nothing is shipped unless it says Built.
| Neuron/synapse foundation, owner-scoped client, module activation, AppHost composition, testing path | **Built** |
| Typed AI, Tasks, Google, Salesforce, Chat, Flutter, Quickstart families | **Built** |
| Flutter vertical — shell/scene vocabulary, UI HTTP/SSE edge, `WithUIEdge`/`WithFlutterHost`, headless Dart host, Windows chrome | **Built** |
+| Product MCP surface — durable chat send/read, neuron journal observation, active-neuron discovery | **Built** |
| Time — durable one-shot `ICountdown` and its recovery tests | **Built** |
| Time — reminders, recurring interval/calendar scheduling, DST | Designed |
| Product chrome polish, multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed |
| Behavior rail — proposal, approval, installation, execution, rollback | Designed |
-| Observability spine — host OpenTelemetry, instrumented chat clients, GenAI spans | **Not built — top open defect** |
+| Observability spine — host OpenTelemetry, structured logs, causal kernel spans, GenAI spans and metrics | **Built** |
`DigitalBrain.Behaviors` is a packable SDK foundation (authoring interfaces, constrained context,
manifests, artifact identities) and holds the canonical artifact codec. It is not a compiler, builder,
@@ -70,6 +71,14 @@ git clean -fdx
aspire run
```
+[scripts/verify-product.ps1](scripts/verify-product.ps1) performs the Release build and a live,
+self-cleaning Aspire proof across resource health, MCP discovery, Gemma4 chat, durable journals, and
+OpenTelemetry:
+
+```powershell
+./scripts/verify-product.ps1
+```
+
[CLAUDE.md](CLAUDE.md) is the working discipline for every agent and contributor: the gates, the
verification ladder, and the traps. A green test suite is necessary, not sufficient — it proves the
code holds, not that a behaviour works.
diff --git a/scripts/verify-product.ps1 b/scripts/verify-product.ps1
new file mode 100644
index 00000000..f56b516d
--- /dev/null
+++ b/scripts/verify-product.ps1
@@ -0,0 +1,269 @@
+[CmdletBinding()]
+param(
+ [string]$Prompt = "Who are you and what can you actually do?",
+ [string]$ChatName = "product-verification-$([Guid]::NewGuid().ToString('N'))",
+ [ValidateRange(1, 180)]
+ [int]$TimeoutSeconds = 180,
+ [switch]$KeepRunning
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$repository = Split-Path -Parent $PSScriptRoot
+$appHost = Join-Path $repository "os/DigitalBrain.OS.AppHost/DigitalBrain.OS.AppHost.csproj"
+$started = $false
+
+function Invoke-Checked {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Command,
+ [Parameter(Mandatory)]
+ [string[]]$Arguments
+ )
+
+ & $Command @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "'$Command $($Arguments -join ' ')' exited with code $LASTEXITCODE."
+ }
+}
+
+function Invoke-AspireJson {
+ param(
+ [Parameter(Mandatory)]
+ [string[]]$Arguments
+ )
+
+ $output = @(& aspire @Arguments 2>&1)
+ if ($LASTEXITCODE -ne 0) {
+ throw "'aspire $($Arguments -join ' ')' exited with code $LASTEXITCODE."
+ }
+
+ $lines = @($output | ForEach-Object { $_.ToString() })
+ if ($lines.Count -eq 0) {
+ return $null
+ }
+
+ $jsonStart = 0..($lines.Count - 1) |
+ Where-Object { $lines[$_].TrimStart() -match "^[\[\{]" } |
+ Select-Object -First 1
+ if ($null -eq $jsonStart) {
+ return $null
+ }
+
+ return ($lines[$jsonStart..($lines.Count - 1)] -join "`n") |
+ ConvertFrom-Json
+}
+
+function Require {
+ param(
+ [Parameter(Mandatory)]
+ [bool]$Condition,
+ [Parameter(Mandatory)]
+ [string]$Message
+ )
+
+ if (-not $Condition) {
+ throw $Message
+ }
+}
+
+Push-Location $repository
+try {
+ & aspire stop --apphost $appHost --non-interactive 2>$null
+
+ Invoke-Checked dotnet @("build", "DigitalBrain.slnx", "-c", "Release")
+ Invoke-Checked aspire @(
+ "start",
+ "--apphost", $appHost,
+ "--format", "Json",
+ "--non-interactive")
+ $started = $true
+
+ foreach ($resource in @(
+ "silo",
+ "digitalbrain-mcp",
+ "digitalbrain-ui",
+ "brain-ai-gemma4",
+ "digitalbrain-flutter")) {
+ Invoke-Checked aspire @(
+ "wait", $resource,
+ "--apphost", $appHost,
+ "--timeout", "180",
+ "--non-interactive")
+ }
+
+ $tools = @()
+ for ($attempt = 1; $attempt -le 90 -and $tools.Count -eq 0; $attempt++) {
+ $tools = @(
+ Invoke-AspireJson @(
+ "mcp", "tools",
+ "--apphost", $appHost,
+ "--format", "Json",
+ "--non-interactive"))
+ if ($tools.Count -eq 0) {
+ Start-Sleep -Seconds 1
+ }
+ }
+ $toolNames = @($tools | ForEach-Object tool)
+ Write-Host "Discovered MCP tools: $($toolNames -join ', ')"
+ $expectedTools = @(
+ "list_active_neurons",
+ "read_chat_transcript",
+ "read_neuron_journal",
+ "send_chat_message")
+ Require `
+ -Condition ($toolNames.Count -eq $expectedTools.Count) `
+ -Message "Expected $($expectedTools.Count) MCP tools but found $($toolNames.Count)."
+ foreach ($tool in $expectedTools) {
+ Require `
+ -Condition ($toolNames -contains $tool) `
+ -Message "MCP tool '$tool' was not discovered."
+ }
+ Require `
+ -Condition ($toolNames -notcontains "ask_llama32") `
+ -Message "The retired model-specific MCP tool is still exposed."
+
+ $sendInput = @{
+ text = $Prompt
+ chatName = $ChatName
+ timeoutSeconds = $TimeoutSeconds
+ } | ConvertTo-Json -Compress
+ $result = Invoke-AspireJson @(
+ "mcp", "call",
+ "digitalbrain-mcp", "send_chat_message",
+ "--input", $sendInput,
+ "--apphost", $appHost,
+ "--non-interactive")
+
+ Require `
+ -Condition (-not [string]::IsNullOrWhiteSpace($result.response)) `
+ -Message "DigitalBrain returned an empty assistant response."
+ Require `
+ -Condition ($result.commandId -match "^[0-9a-f]{32}$") `
+ -Message "The MCP result did not return a valid command ID."
+ Require `
+ -Condition ($result.correlationId -match "^[0-9a-f]{32}$") `
+ -Message "The MCP result did not return a valid correlation ID."
+
+ $chatInput = @{ chatName = $ChatName } | ConvertTo-Json -Compress
+ $transcript = Invoke-AspireJson @(
+ "mcp", "call",
+ "digitalbrain-mcp", "read_chat_transcript",
+ "--input", $chatInput,
+ "--apphost", $appHost,
+ "--non-interactive")
+ Require `
+ -Condition ($transcript.turns.Count -eq 2) `
+ -Message "Expected one user and one assistant transcript turn."
+ Require `
+ -Condition (
+ $transcript.turns[0].speaker -eq "you" -and
+ $transcript.turns[0].text -eq $Prompt) `
+ -Message "The durable transcript does not contain the sent user turn."
+ Require `
+ -Condition (
+ $transcript.turns[1].speaker -eq "brain" -and
+ $transcript.turns[1].text -eq $result.response) `
+ -Message "The durable transcript does not contain the returned assistant turn."
+
+ $journalInput = @{
+ grainType = "chat"
+ name = $ChatName
+ kind = "outgoing"
+ afterSequence = 0
+ } | ConvertTo-Json -Compress
+ $journal = Invoke-AspireJson @(
+ "mcp", "call",
+ "digitalbrain-mcp", "read_neuron_journal",
+ "--input", $journalInput,
+ "--apphost", $appHost,
+ "--non-interactive")
+ Require `
+ -Condition ($journal.entries.Count -eq 2) `
+ -Message "Expected exactly two outgoing chat journal entries."
+ Require `
+ -Condition (
+ $journal.entries[0].synapse -eq "UserMessaged" -and
+ $journal.entries[1].synapse -eq "AssistantResponded") `
+ -Message "Chat journal entries are missing or out of order."
+ Require `
+ -Condition (
+ $journal.entries[0].correlation -eq $result.correlationId -and
+ $journal.entries[1].correlation -eq $result.correlationId) `
+ -Message "Chat journal entries do not share the MCP result correlation."
+
+ $genAi = $null
+ for ($attempt = 1; $attempt -le 60 -and $null -eq $genAi; $attempt++) {
+ $genAiSpans = @(
+ Invoke-AspireJson @(
+ "otel", "spans",
+ "--apphost", $appHost,
+ "--format", "Json",
+ "--limit", "50",
+ "--search", "gen_ai",
+ "--non-interactive"))
+ $genAi = $genAiSpans |
+ Where-Object {
+ $_.name -like "chat gemma4:*" -and
+ $_.attributes.'gen_ai.operation.name' -eq "chat" -and
+ $_.attributes.'gen_ai.provider.name' -eq "ollama"
+ } |
+ Select-Object -First 1
+ if ($null -eq $genAi) {
+ Start-Sleep -Seconds 1
+ }
+ }
+ Require `
+ -Condition ($null -ne $genAi) `
+ -Message "No Gemma4 OpenTelemetry chat span was exported."
+ Require `
+ -Condition (
+ -not $genAi.attributes.PSObject.Properties[
+ "gen_ai.input.messages"] -and
+ -not $genAi.attributes.PSObject.Properties[
+ "gen_ai.output.messages"]) `
+ -Message "The GenAI span exported sensitive message content."
+
+ $completion = $null
+ for ($attempt = 1; $attempt -le 60 -and $null -eq $completion; $attempt++) {
+ $toolLogs = @(
+ Invoke-AspireJson @(
+ "otel", "logs",
+ "--apphost", $appHost,
+ "--format", "Json",
+ "--limit", "20",
+ "--search", "send_chat_message",
+ "--non-interactive"))
+ $completion = $toolLogs |
+ Where-Object {
+ $_.attributes.ToolName -eq "send_chat_message" -and
+ $_.attributes.IsError -eq "false"
+ } |
+ Select-Object -First 1
+ if ($null -eq $completion) {
+ Start-Sleep -Seconds 1
+ }
+ }
+ Require `
+ -Condition ($null -ne $completion) `
+ -Message "No successful structured MCP completion log was exported."
+
+ [pscustomobject]@{
+ Chat = $result.chat
+ CommandId = $result.commandId
+ CorrelationId = $result.correlationId
+ Response = $result.response
+ GenAiTraceId = $genAi.traceId
+ GenAiDurationMs = $genAi.durationMs
+ InputTokens = $genAi.attributes.'gen_ai.usage.input_tokens'
+ OutputTokens = $genAi.attributes.'gen_ai.usage.output_tokens'
+ } | Format-List
+}
+finally {
+ Pop-Location
+
+ if ($started -and -not $KeepRunning) {
+ & aspire stop --apphost $appHost --non-interactive
+ }
+}
From 8b4b6f68f73bea72f70297ef040db1dd7b692cbe Mon Sep 17 00:00:00 2001
From: Vladyslav Horbachov <0965142838gvs@gmail.com>
Date: Tue, 28 Jul 2026 07:47:12 +0200
Subject: [PATCH 06/20] feat: add observable Flutter workspace
Adds responsive Chat, Activity, and Brain surfaces over one shared durable chat projection. Activity exposes sequence and command facts without message content, while Brain describes only the product's measured proposal-gated capabilities.\n\nEvery added surface has a current UI consumer. Claims were checked by Flutter/Dart analysis and tests, the Windows Release build, and the 253-test root MTP gate. The three pre-existing Flutter generated-file changes remain unstaged and untouched.
---
README.md | 3 +-
.../shell/lib/activity_screen.dart | 146 +++++
.../shell/lib/brain_screen.dart | 279 +++++++++
.../shell/lib/brain_theme.dart | 126 +++-
.../shell/lib/chat_screen.dart | 557 +++++++++++++-----
.../digitalbrain_flutter/shell/lib/main.dart | 2 +-
.../shell/test/chat_screen_test.dart | 110 +++-
7 files changed, 1048 insertions(+), 175 deletions(-)
create mode 100644 clients/digitalbrain_flutter/shell/lib/activity_screen.dart
create mode 100644 clients/digitalbrain_flutter/shell/lib/brain_screen.dart
diff --git a/README.md b/README.md
index d02819c5..abc13fdf 100644
--- a/README.md
+++ b/README.md
@@ -37,10 +37,11 @@ The plan of record. Nothing is shipped unless it says Built.
| Neuron/synapse foundation, owner-scoped client, module activation, AppHost composition, testing path | **Built** |
| Typed AI, Tasks, Google, Salesforce, Chat, Flutter, Quickstart families | **Built** |
| Flutter vertical — shell/scene vocabulary, UI HTTP/SSE edge, `WithUIEdge`/`WithFlutterHost`, headless Dart host, Windows chrome | **Built** |
+| Product shell — responsive Chat, content-safe Activity, capability-accurate Brain surfaces | **Built** |
| Product MCP surface — durable chat send/read, neuron journal observation, active-neuron discovery | **Built** |
| Time — durable one-shot `ICountdown` and its recovery tests | **Built** |
| Time — reminders, recurring interval/calendar scheduling, DST | Designed |
-| Product chrome polish, multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed |
+| Multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed |
| Behavior rail — proposal, approval, installation, execution, rollback | Designed |
| Observability spine — host OpenTelemetry, structured logs, causal kernel spans, GenAI spans and metrics | **Built** |
diff --git a/clients/digitalbrain_flutter/shell/lib/activity_screen.dart b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart
new file mode 100644
index 00000000..999ba9f9
--- /dev/null
+++ b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart
@@ -0,0 +1,146 @@
+import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
+import 'package:flutter/material.dart';
+
+import 'brain_theme.dart';
+
+final class ActivityScreen extends StatelessWidget {
+ const ActivityScreen({super.key, required this.turns});
+
+ final List turns;
+
+ @override
+ Widget build(BuildContext context) {
+ return ColoredBox(
+ key: const Key('activity_screen'),
+ color: BrainPalette.surface,
+ child: turns.isEmpty
+ ? const _EmptyActivity()
+ : Align(
+ alignment: Alignment.topCenter,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 980),
+ child: ListView(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 32,
+ ),
+ children: [
+ const _ActivityHeader(),
+ const SizedBox(height: 24),
+ for (final turn in turns.reversed)
+ _ActivityEntry(turn: turn),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+final class _ActivityHeader extends StatelessWidget {
+ const _ActivityHeader();
+
+ @override
+ Widget build(BuildContext context) {
+ return const Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Activity', style: BrainType.heading),
+ SizedBox(height: 8),
+ Text(
+ 'Durable conversation facts, newest first. Message content stays in Chat.',
+ style: BrainType.bodyMuted,
+ ),
+ ],
+ );
+ }
+}
+
+final class _EmptyActivity extends StatelessWidget {
+ const _EmptyActivity();
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(
+ Icons.timeline_rounded,
+ size: 34,
+ color: BrainPalette.textMuted,
+ ),
+ const SizedBox(height: 16),
+ const Text('No activity yet.', style: BrainType.empty),
+ const SizedBox(height: 7),
+ Text(
+ 'Journal facts appear after the conversation begins.',
+ style: BrainType.body.copyWith(color: BrainPalette.textMuted),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+final class _ActivityEntry extends StatelessWidget {
+ const _ActivityEntry({required this.turn});
+
+ final ChatTurnEvent turn;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = turn.fromUser ? BrainPalette.owner : BrainPalette.signal;
+ final event = turn.fromUser ? 'UserMessaged' : 'AssistantResponded';
+
+ return Container(
+ margin: const EdgeInsets.only(bottom: 12),
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ color: BrainPalette.surfaceRaised,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: BrainPalette.line),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 34,
+ height: 34,
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Icon(
+ turn.fromUser
+ ? Icons.north_east_rounded
+ : Icons.south_west_rounded,
+ color: color,
+ size: 17,
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(event, style: BrainType.metaStrong.copyWith(color: color)),
+ const SizedBox(height: 6),
+ Wrap(
+ spacing: 18,
+ runSpacing: 5,
+ children: [
+ Text(
+ 'sequence ${turn.sequence.toString().padLeft(3, '0')}',
+ style: BrainType.meta,
+ ),
+ Text('command ${turn.commandId}', style: BrainType.meta),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart
new file mode 100644
index 00000000..c5256634
--- /dev/null
+++ b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart
@@ -0,0 +1,279 @@
+import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
+import 'package:flutter/material.dart';
+
+import 'brain_theme.dart';
+
+final class BrainScreen extends StatelessWidget {
+ const BrainScreen({
+ super.key,
+ required this.chatName,
+ required this.turns,
+ this.statusMessage,
+ });
+
+ final String chatName;
+ final List turns;
+ final String? statusMessage;
+
+ @override
+ Widget build(BuildContext context) {
+ final connected = statusMessage == null || statusMessage!.isEmpty;
+ final lastSequence = turns.isEmpty ? '—' : '${turns.last.sequence}';
+
+ return ColoredBox(
+ key: const Key('brain_screen'),
+ color: BrainPalette.surface,
+ child: Align(
+ alignment: Alignment.topCenter,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 980),
+ child: ListView(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
+ children: [
+ const Text('Brain', style: BrainType.heading),
+ const SizedBox(height: 8),
+ const Text(
+ 'What this DigitalBrain can do, and what remains under your control.',
+ style: BrainType.bodyMuted,
+ ),
+ const SizedBox(height: 26),
+ Wrap(
+ spacing: 12,
+ runSpacing: 12,
+ children: [
+ _MetricCard(
+ label: 'Runtime',
+ value: connected ? 'Connected' : 'Offline',
+ accent: connected
+ ? BrainPalette.success
+ : BrainPalette.signal,
+ ),
+ _MetricCard(label: 'Conversation', value: chatName),
+ _MetricCard(label: 'Last sequence', value: lastSequence),
+ ],
+ ),
+ const SizedBox(height: 30),
+ const _SectionLabel('CAPABILITIES'),
+ const SizedBox(height: 12),
+ const _CapabilityCard(
+ icon: Icons.chat_bubble_outline_rounded,
+ title: 'General assistant',
+ body:
+ 'Conversation, explanation, drafting, and reasoning in the current chat.',
+ ),
+ const SizedBox(height: 12),
+ const _CapabilityCard(
+ icon: Icons.compare_arrows_rounded,
+ title: 'Gmail message → Salesforce Account description',
+ body:
+ 'Creates a reviewable enrichment proposal from an exact Gmail message ID and a Salesforce Account ID.',
+ badge: 'Approval required',
+ ),
+ const SizedBox(height: 30),
+ const _SectionLabel('BOUNDARIES'),
+ const SizedBox(height: 12),
+ const _BoundaryCard(),
+ if (!connected) ...[
+ const SizedBox(height: 20),
+ _ConnectionNotice(message: statusMessage!),
+ ],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+final class _MetricCard extends StatelessWidget {
+ const _MetricCard({
+ required this.label,
+ required this.value,
+ this.accent = BrainPalette.textPrimary,
+ });
+
+ final String label;
+ final String value;
+ final Color accent;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 190,
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: BrainPalette.surfaceRaised,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: BrainPalette.line),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label, style: BrainType.meta),
+ const SizedBox(height: 9),
+ Text(
+ value,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: BrainType.metric.copyWith(color: accent),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+final class _SectionLabel extends StatelessWidget {
+ const _SectionLabel(this.text);
+
+ final String text;
+
+ @override
+ Widget build(BuildContext context) {
+ return Text(text, style: BrainType.meta);
+ }
+}
+
+final class _CapabilityCard extends StatelessWidget {
+ const _CapabilityCard({
+ required this.icon,
+ required this.title,
+ required this.body,
+ this.badge,
+ });
+
+ final IconData icon;
+ final String title;
+ final String body;
+ final String? badge;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.all(20),
+ decoration: BoxDecoration(
+ color: BrainPalette.surfaceRaised,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: BrainPalette.line),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 38,
+ height: 38,
+ decoration: BoxDecoration(
+ color: BrainPalette.signal.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(11),
+ ),
+ child: Icon(icon, color: BrainPalette.signal, size: 19),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(child: Text(title, style: BrainType.cardTitle)),
+ if (badge != null) _Badge(label: badge!),
+ ],
+ ),
+ const SizedBox(height: 7),
+ Text(body, style: BrainType.bodyMuted),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+final class _Badge extends StatelessWidget {
+ const _Badge({required this.label});
+
+ final String label;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
+ decoration: BoxDecoration(
+ color: BrainPalette.owner.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: BrainPalette.owner.withValues(alpha: 0.3)),
+ ),
+ child: Text(
+ label,
+ style: BrainType.metaStrong.copyWith(color: BrainPalette.owner),
+ ),
+ );
+ }
+}
+
+final class _BoundaryCard extends StatelessWidget {
+ const _BoundaryCard();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.all(20),
+ decoration: BoxDecoration(
+ color: BrainPalette.surfaceSunken,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: BrainPalette.line),
+ ),
+ child: const Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _BoundaryLine('No Gmail search, listing, or sending'),
+ SizedBox(height: 11),
+ _BoundaryLine('No direct Salesforce writes'),
+ SizedBox(height: 11),
+ _BoundaryLine('No account, contact, or lead creation'),
+ ],
+ ),
+ );
+ }
+}
+
+final class _BoundaryLine extends StatelessWidget {
+ const _BoundaryLine(this.text);
+
+ final String text;
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ children: [
+ const Icon(
+ Icons.remove_circle_outline_rounded,
+ color: BrainPalette.textMuted,
+ size: 16,
+ ),
+ const SizedBox(width: 10),
+ Expanded(child: Text(text, style: BrainType.bodyMuted)),
+ ],
+ );
+ }
+}
+
+final class _ConnectionNotice extends StatelessWidget {
+ const _ConnectionNotice({required this.message});
+
+ final String message;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: BrainPalette.signal.withValues(alpha: 0.08),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: BrainPalette.signal.withValues(alpha: 0.25)),
+ ),
+ child: Text(message, style: BrainType.bodyMuted),
+ );
+ }
+}
diff --git a/clients/digitalbrain_flutter/shell/lib/brain_theme.dart b/clients/digitalbrain_flutter/shell/lib/brain_theme.dart
index afd811c2..4150cc93 100644
--- a/clients/digitalbrain_flutter/shell/lib/brain_theme.dart
+++ b/clients/digitalbrain_flutter/shell/lib/brain_theme.dart
@@ -1,24 +1,72 @@
import 'package:flutter/material.dart';
-/// The conversation is a journal you can talk to, so the surface reads as an
-/// instrument rather than a messenger: deep slate-indigo, two voices separated
-/// by colour temperature, and a monospace rail carrying real journal sequence.
abstract final class BrainPalette {
- static const surface = Color(0xFF14161D);
- static const surfaceRaised = Color(0xFF1C1F29);
- static const line = Color(0xFF2A2E3B);
- static const textPrimary = Color(0xFFE6E8EF);
- static const textMuted = Color(0xFF8A90A3);
+ static const navigation = Color(0xFF101219);
+ static const surface = Color(0xFF141720);
+ static const surfaceRaised = Color(0xFF1B1F2A);
+ static const surfaceSunken = Color(0xFF11141B);
+ static const line = Color(0xFF292E3B);
+ static const lineStrong = Color(0xFF363C4C);
+ static const textPrimary = Color(0xFFE9EBF2);
+ static const textMuted = Color(0xFF969CAF);
+ static const textFaint = Color(0xFF5E6474);
+ static const signal = Color(0xFFE09261);
+ static const owner = Color(0xFF7B9BE3);
+ static const success = Color(0xFF65C5A0);
+}
- /// The brain answering.
- static const signal = Color(0xFFD98A5B);
+abstract final class BrainTheme {
+ static ThemeData dark() {
+ final scheme = ColorScheme.fromSeed(
+ seedColor: BrainPalette.signal,
+ brightness: Brightness.dark,
+ surface: BrainPalette.surface,
+ );
- /// The owner speaking.
- static const owner = Color(0xFF6E8FD4);
+ return ThemeData(
+ useMaterial3: true,
+ brightness: Brightness.dark,
+ scaffoldBackgroundColor: BrainPalette.surface,
+ colorScheme: scheme.copyWith(
+ primary: BrainPalette.signal,
+ secondary: BrainPalette.owner,
+ surface: BrainPalette.surface,
+ ),
+ dividerColor: BrainPalette.line,
+ navigationRailTheme: NavigationRailThemeData(
+ backgroundColor: BrainPalette.navigation,
+ indicatorColor: BrainPalette.signal.withValues(alpha: 0.14),
+ selectedIconTheme: const IconThemeData(
+ color: BrainPalette.signal,
+ size: 21,
+ ),
+ unselectedIconTheme: const IconThemeData(
+ color: BrainPalette.textMuted,
+ size: 20,
+ ),
+ selectedLabelTextStyle: BrainType.metaStrong.copyWith(
+ color: BrainPalette.signal,
+ ),
+ unselectedLabelTextStyle: BrainType.meta,
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: BrainPalette.navigation,
+ indicatorColor: BrainPalette.signal.withValues(alpha: 0.14),
+ labelTextStyle: WidgetStateProperty.resolveWith(
+ (states) => states.contains(WidgetState.selected)
+ ? BrainType.metaStrong.copyWith(color: BrainPalette.signal)
+ : BrainType.meta,
+ ),
+ ),
+ tooltipTheme: const TooltipThemeData(
+ decoration: BoxDecoration(color: BrainPalette.surfaceRaised),
+ textStyle: BrainType.meta,
+ ),
+ );
+ }
}
abstract final class BrainType {
- /// Windows ships Cascadia; the fallbacks keep the rail monospace elsewhere.
static const monoFamily = 'Cascadia Mono';
static const monoFallback = ['Consolas', 'Menlo', 'monospace'];
@@ -34,6 +82,33 @@ abstract final class BrainType {
color: BrainPalette.textPrimary,
);
+ static const heading = TextStyle(
+ fontFamily: bodyFamily,
+ fontFamilyFallback: bodyFallback,
+ fontSize: 28,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.8,
+ color: BrainPalette.textPrimary,
+ );
+
+ static const cardTitle = TextStyle(
+ fontFamily: bodyFamily,
+ fontFamilyFallback: bodyFallback,
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.15,
+ color: BrainPalette.textPrimary,
+ );
+
+ static const metric = TextStyle(
+ fontFamily: bodyFamily,
+ fontFamilyFallback: bodyFallback,
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.2,
+ color: BrainPalette.textPrimary,
+ );
+
static const body = TextStyle(
fontFamily: bodyFamily,
fontFamilyFallback: bodyFallback,
@@ -42,21 +117,38 @@ abstract final class BrainType {
color: BrainPalette.textPrimary,
);
+ static const bodyMuted = TextStyle(
+ fontFamily: bodyFamily,
+ fontFamilyFallback: bodyFallback,
+ fontSize: 14,
+ height: 1.5,
+ color: BrainPalette.textMuted,
+ );
+
static const meta = TextStyle(
fontFamily: monoFamily,
fontFamilyFallback: monoFallback,
fontSize: 11,
fontWeight: FontWeight.w500,
- letterSpacing: 0.5,
+ letterSpacing: 0.45,
color: BrainPalette.textMuted,
);
+ static const metaStrong = TextStyle(
+ fontFamily: monoFamily,
+ fontFamilyFallback: monoFallback,
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.45,
+ color: BrainPalette.textPrimary,
+ );
+
static const empty = TextStyle(
fontFamily: bodyFamily,
fontFamilyFallback: bodyFallback,
- fontSize: 17,
- fontWeight: FontWeight.w500,
- letterSpacing: -0.2,
+ fontSize: 18,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.3,
color: BrainPalette.textPrimary,
);
}
diff --git a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart
index 0fa71573..b9faa34b 100644
--- a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart
+++ b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart
@@ -4,6 +4,8 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import 'activity_screen.dart';
+import 'brain_screen.dart';
import 'brain_theme.dart';
typedef SendMessage = Future Function(String text);
@@ -27,17 +29,8 @@ final class BrainChatApp extends StatelessWidget {
return MaterialApp(
title: 'DigitalBrain',
debugShowCheckedModeBanner: false,
- theme: ThemeData(
- useMaterial3: true,
- brightness: Brightness.dark,
- scaffoldBackgroundColor: BrainPalette.surface,
- colorScheme: ColorScheme.fromSeed(
- seedColor: BrainPalette.signal,
- brightness: Brightness.dark,
- surface: BrainPalette.surface,
- ),
- ),
- home: BrainChatScreen(
+ theme: BrainTheme.dark(),
+ home: _BrainWorkspace(
chatName: chatName,
turns: turns,
onSend: onSend,
@@ -47,9 +40,8 @@ final class BrainChatApp extends StatelessWidget {
}
}
-final class BrainChatScreen extends StatefulWidget {
- const BrainChatScreen({
- super.key,
+final class _BrainWorkspace extends StatefulWidget {
+ const _BrainWorkspace({
required this.chatName,
this.turns,
this.onSend,
@@ -62,19 +54,17 @@ final class BrainChatScreen extends StatefulWidget {
final String? statusMessage;
@override
- State createState() => _BrainChatScreenState();
+ State<_BrainWorkspace> createState() => _BrainWorkspaceState();
}
-final class _BrainChatScreenState extends State {
+final class _BrainWorkspaceState extends State<_BrainWorkspace> {
+ static const _compactBreakpoint = 720.0;
+
final _turns = [];
final _seen = {};
- final _composer = TextEditingController();
- final _composerFocus = FocusNode();
- final _scroll = ScrollController();
-
StreamSubscription? _subscription;
- bool _awaitingBrain = false;
- String? _failure;
+ int _destination = 0;
+ String? _streamFailure;
@override
void initState() {
@@ -83,28 +73,307 @@ final class _BrainChatScreenState extends State {
}
@override
- void didUpdateWidget(covariant BrainChatScreen oldWidget) {
+ void didUpdateWidget(covariant _BrainWorkspace oldWidget) {
super.didUpdateWidget(oldWidget);
+ if (oldWidget.chatName != widget.chatName) {
+ _turns.clear();
+ _seen.clear();
+ }
if (!identical(oldWidget.turns, widget.turns)) {
- _subscription?.cancel();
+ unawaited(_subscription?.cancel());
_listen(widget.turns);
}
}
void _listen(Stream? turns) {
- _subscription = turns?.listen((turn) {
- if (!mounted || !_seen.add(turn.sequence)) {
- return;
- }
- setState(() {
- _turns.add(turn);
- _turns.sort((a, b) => a.sequence.compareTo(b.sequence));
- if (!turn.fromUser) {
- _awaitingBrain = false;
+ _streamFailure = null;
+ _subscription = turns?.listen(
+ (turn) {
+ if (!mounted || !_seen.add(turn.sequence)) {
+ return;
}
- });
+ setState(() {
+ _turns.add(turn);
+ _turns.sort((a, b) => a.sequence.compareTo(b.sequence));
+ });
+ },
+ onError: (Object error) {
+ if (mounted) {
+ setState(() => _streamFailure = '$error');
+ }
+ },
+ );
+ }
+
+ String? get _statusMessage => widget.statusMessage ?? _streamFailure;
+
+ void _selectDestination(int index) {
+ if (_destination != index) {
+ setState(() => _destination = index);
+ }
+ }
+
+ @override
+ void dispose() {
+ unawaited(_subscription?.cancel());
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final projectedTurns = List.unmodifiable(_turns);
+ final pages = [
+ BrainChatScreen(
+ chatName: widget.chatName,
+ turns: projectedTurns,
+ onSend: widget.onSend,
+ ),
+ ActivityScreen(turns: projectedTurns),
+ BrainScreen(
+ chatName: widget.chatName,
+ turns: projectedTurns,
+ statusMessage: _statusMessage,
+ ),
+ ];
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final compact = constraints.maxWidth < _compactBreakpoint;
+ final content = Column(
+ children: [
+ _StatusBar(
+ chatName: widget.chatName,
+ section: _sectionName(_destination),
+ message: _statusMessage,
+ ),
+ Expanded(
+ child: IndexedStack(index: _destination, children: pages),
+ ),
+ ],
+ );
+
+ return Scaffold(
+ body: compact
+ ? content
+ : Row(
+ children: [
+ _WorkspaceRail(
+ selectedIndex: _destination,
+ onSelected: _selectDestination,
+ ),
+ const VerticalDivider(width: 1, thickness: 1),
+ Expanded(child: content),
+ ],
+ ),
+ bottomNavigationBar: compact
+ ? _WorkspaceNavigationBar(
+ selectedIndex: _destination,
+ onSelected: _selectDestination,
+ )
+ : null,
+ );
+ },
+ );
+ }
+}
+
+String _sectionName(int index) => switch (index) {
+ 0 => 'Chat',
+ 1 => 'Activity',
+ _ => 'Brain',
+};
+
+final class _WorkspaceRail extends StatelessWidget {
+ const _WorkspaceRail({required this.selectedIndex, required this.onSelected});
+
+ final int selectedIndex;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ return NavigationRail(
+ backgroundColor: BrainPalette.navigation,
+ minWidth: 88,
+ groupAlignment: -0.78,
+ labelType: NavigationRailLabelType.all,
+ selectedIndex: selectedIndex,
+ onDestinationSelected: onSelected,
+ leading: const Padding(
+ padding: EdgeInsets.only(top: 10, bottom: 28),
+ child: _BrainMark(),
+ ),
+ destinations: _destinations(),
+ );
+ }
+}
+
+final class _WorkspaceNavigationBar extends StatelessWidget {
+ const _WorkspaceNavigationBar({
+ required this.selectedIndex,
+ required this.onSelected,
+ });
+
+ final int selectedIndex;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ return NavigationBar(
+ selectedIndex: selectedIndex,
+ onDestinationSelected: onSelected,
+ destinations: const [
+ NavigationDestination(
+ icon: Icon(Icons.forum_outlined, key: Key('destination_chat')),
+ selectedIcon: Icon(Icons.forum, key: Key('destination_chat')),
+ label: 'Chat',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.timeline_outlined, key: Key('destination_activity')),
+ selectedIcon: Icon(Icons.timeline, key: Key('destination_activity')),
+ label: 'Activity',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.hub_outlined, key: Key('destination_brain')),
+ selectedIcon: Icon(Icons.hub, key: Key('destination_brain')),
+ label: 'Brain',
+ ),
+ ],
+ );
+ }
+}
+
+List _destinations() => const [
+ NavigationRailDestination(
+ icon: Icon(Icons.forum_outlined, key: Key('destination_chat')),
+ selectedIcon: Icon(Icons.forum, key: Key('destination_chat')),
+ label: Text('Chat'),
+ ),
+ NavigationRailDestination(
+ icon: Icon(Icons.timeline_outlined, key: Key('destination_activity')),
+ selectedIcon: Icon(Icons.timeline, key: Key('destination_activity')),
+ label: Text('Activity'),
+ ),
+ NavigationRailDestination(
+ icon: Icon(Icons.hub_outlined, key: Key('destination_brain')),
+ selectedIcon: Icon(Icons.hub, key: Key('destination_brain')),
+ label: Text('Brain'),
+ ),
+];
+
+final class _BrainMark extends StatelessWidget {
+ const _BrainMark();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 36,
+ height: 36,
+ decoration: BoxDecoration(
+ color: BrainPalette.signal.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: BrainPalette.signal.withValues(alpha: 0.4)),
+ ),
+ child: const Icon(
+ Icons.graphic_eq_rounded,
+ color: BrainPalette.signal,
+ size: 20,
+ ),
+ );
+ }
+}
+
+final class _StatusBar extends StatelessWidget {
+ const _StatusBar({
+ required this.chatName,
+ required this.section,
+ this.message,
+ });
+
+ final String chatName;
+ final String section;
+ final String? message;
+
+ @override
+ Widget build(BuildContext context) {
+ final offline = message != null && message!.isNotEmpty;
+
+ return Container(
+ height: 58,
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ decoration: const BoxDecoration(
+ color: BrainPalette.surfaceRaised,
+ border: Border(bottom: BorderSide(color: BrainPalette.line)),
+ ),
+ child: Row(
+ children: [
+ const Text('DigitalBrain', style: BrainType.title),
+ const SizedBox(width: 10),
+ Container(width: 1, height: 16, color: BrainPalette.lineStrong),
+ const SizedBox(width: 10),
+ Text(section, style: BrainType.metaStrong),
+ const Spacer(),
+ Text('chat:$chatName', style: BrainType.meta),
+ const SizedBox(width: 14),
+ Container(
+ width: 7,
+ height: 7,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: offline ? BrainPalette.signal : BrainPalette.success,
+ boxShadow: [
+ BoxShadow(
+ color: (offline ? BrainPalette.signal : BrainPalette.success)
+ .withValues(alpha: 0.35),
+ blurRadius: 8,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(offline ? 'not connected' : 'connected', style: BrainType.meta),
+ ],
+ ),
+ );
+ }
+}
+
+final class BrainChatScreen extends StatefulWidget {
+ const BrainChatScreen({
+ super.key,
+ required this.chatName,
+ required this.turns,
+ this.onSend,
+ });
+
+ final String chatName;
+ final List turns;
+ final SendMessage? onSend;
+
+ @override
+ State createState() => _BrainChatScreenState();
+}
+
+final class _BrainChatScreenState extends State {
+ final _composer = TextEditingController();
+ final _composerFocus = FocusNode();
+ final _scroll = ScrollController();
+
+ bool _awaitingBrain = false;
+ int _awaitingAfterSequence = 0;
+ String? _failure;
+
+ @override
+ void didUpdateWidget(covariant BrainChatScreen oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.turns.length != oldWidget.turns.length) {
+ if (_awaitingBrain &&
+ widget.turns.any(
+ (turn) => !turn.fromUser && turn.sequence > _awaitingAfterSequence,
+ )) {
+ setState(() => _awaitingBrain = false);
+ }
_scrollToLatest();
- });
+ }
}
void _scrollToLatest() {
@@ -130,6 +399,9 @@ final class _BrainChatScreenState extends State {
_composer.clear();
setState(() {
_awaitingBrain = true;
+ _awaitingAfterSequence = widget.turns.isEmpty
+ ? 0
+ : widget.turns.last.sequence;
_failure = null;
});
@@ -148,7 +420,6 @@ final class _BrainChatScreenState extends State {
@override
void dispose() {
- _subscription?.cancel();
_composer.dispose();
_composerFocus.dispose();
_scroll.dispose();
@@ -157,15 +428,15 @@ final class _BrainChatScreenState extends State {
@override
Widget build(BuildContext context) {
- return Scaffold(
- body: Column(
+ return ColoredBox(
+ color: BrainPalette.surface,
+ child: Column(
children: [
- _StatusBar(chatName: widget.chatName, message: widget.statusMessage),
Expanded(
- child: _turns.isEmpty && !_awaitingBrain
+ child: widget.turns.isEmpty && !_awaitingBrain
? const _EmptyJournal()
: _Journal(
- turns: _turns,
+ turns: widget.turns,
awaitingBrain: _awaitingBrain,
controller: _scroll,
),
@@ -183,45 +454,6 @@ final class _BrainChatScreenState extends State {
}
}
-final class _StatusBar extends StatelessWidget {
- const _StatusBar({required this.chatName, this.message});
-
- final String chatName;
- final String? message;
-
- @override
- Widget build(BuildContext context) {
- final offline = message != null && message!.isNotEmpty;
-
- return Container(
- height: 44,
- padding: const EdgeInsets.symmetric(horizontal: 20),
- decoration: const BoxDecoration(
- color: BrainPalette.surfaceRaised,
- border: Border(bottom: BorderSide(color: BrainPalette.line)),
- ),
- child: Row(
- children: [
- const Text('DigitalBrain', style: BrainType.title),
- const SizedBox(width: 12),
- Text('chat:$chatName', style: BrainType.meta),
- const Spacer(),
- Container(
- width: 6,
- height: 6,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: offline ? BrainPalette.signal : BrainPalette.owner,
- ),
- ),
- const SizedBox(width: 8),
- Text(offline ? 'not connected' : 'connected', style: BrainType.meta),
- ],
- ),
- );
- }
-}
-
final class _EmptyJournal extends StatelessWidget {
const _EmptyJournal();
@@ -231,8 +463,22 @@ final class _EmptyJournal extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
+ Container(
+ width: 54,
+ height: 54,
+ decoration: BoxDecoration(
+ color: BrainPalette.surfaceRaised,
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: BrainPalette.line),
+ ),
+ child: const Icon(
+ Icons.chat_bubble_outline_rounded,
+ color: BrainPalette.textMuted,
+ ),
+ ),
+ const SizedBox(height: 18),
const Text('Nothing yet.', style: BrainType.empty),
- const SizedBox(height: 8),
+ const SizedBox(height: 7),
Text(
'Ask your brain to do something.',
style: BrainType.body.copyWith(color: BrainPalette.textMuted),
@@ -243,8 +489,6 @@ final class _EmptyJournal extends StatelessWidget {
}
}
-/// The signature: every turn keeps its real journal sequence in a monospace
-/// rail. Order here is durable kernel truth, not scroll position.
final class _Journal extends StatelessWidget {
const _Journal({
required this.turns,
@@ -261,11 +505,11 @@ final class _Journal extends StatelessWidget {
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 760),
+ constraints: const BoxConstraints(maxWidth: 820),
child: ListView.builder(
key: const Key('chat_journal'),
controller: controller,
- padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20),
+ padding: const EdgeInsets.symmetric(vertical: 34, horizontal: 28),
itemCount: turns.length + (awaitingBrain ? 1 : 0),
itemBuilder: (context, index) {
if (index == turns.length) {
@@ -290,12 +534,12 @@ final class _JournalTurn extends StatelessWidget {
return Padding(
key: Key('turn_${turn.sequence}'),
- padding: const EdgeInsets.only(bottom: 26),
+ padding: const EdgeInsets.only(bottom: 28),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
- width: 44,
+ width: 48,
child: Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(
@@ -304,17 +548,24 @@ final class _JournalTurn extends StatelessWidget {
),
),
),
- Container(width: 2, height: 20, color: voice),
- const SizedBox(width: 16),
+ Container(
+ width: 3,
+ height: 22,
+ decoration: BoxDecoration(
+ color: voice,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
turn.fromUser ? 'you' : 'brain',
- style: BrainType.meta.copyWith(color: voice),
+ style: BrainType.metaStrong.copyWith(color: voice),
),
- const SizedBox(height: 6),
+ const SizedBox(height: 7),
SelectableText(turn.text, style: BrainType.body),
],
),
@@ -331,16 +582,23 @@ final class _AwaitingTurn extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
- padding: const EdgeInsets.only(bottom: 26),
+ padding: const EdgeInsets.only(bottom: 28),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- const SizedBox(width: 44),
- Container(width: 2, height: 20, color: BrainPalette.signal),
- const SizedBox(width: 16),
+ const SizedBox(width: 48),
+ Container(
+ width: 3,
+ height: 22,
+ decoration: BoxDecoration(
+ color: BrainPalette.signal,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(width: 18),
Text(
'thinking',
- style: BrainType.meta.copyWith(color: BrainPalette.signal),
+ style: BrainType.metaStrong.copyWith(color: BrainPalette.signal),
),
],
),
@@ -357,8 +615,8 @@ final class _FailureNotice extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
width: double.infinity,
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
- color: BrainPalette.surfaceRaised,
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 11),
+ color: BrainPalette.signal.withValues(alpha: 0.08),
child: Text(
message,
style: BrainType.meta.copyWith(color: BrainPalette.signal),
@@ -387,61 +645,72 @@ final class _Composer extends StatelessWidget {
color: BrainPalette.surfaceRaised,
border: Border(top: BorderSide(color: BrainPalette.line)),
),
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
+ padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 760),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Expanded(
- child: Shortcuts(
- shortcuts: const {
- SingleActivator(LogicalKeyboardKey.enter): _SubmitIntent(),
- },
- child: Actions(
- actions: {
- _SubmitIntent: CallbackAction<_SubmitIntent>(
- onInvoke: (_) {
- unawaited(onSubmit());
- return null;
- },
- ),
+ constraints: const BoxConstraints(maxWidth: 820),
+ child: Container(
+ decoration: BoxDecoration(
+ color: BrainPalette.surfaceSunken,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: BrainPalette.lineStrong),
+ ),
+ padding: const EdgeInsets.only(left: 16, right: 8),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Expanded(
+ child: Shortcuts(
+ shortcuts: const {
+ SingleActivator(LogicalKeyboardKey.enter):
+ _SubmitIntent(),
},
- child: TextField(
- key: const Key('chat_composer'),
- controller: controller,
- focusNode: focusNode,
- enabled: enabled,
- autofocus: true,
- maxLines: 4,
- minLines: 1,
- style: BrainType.body,
- cursorColor: BrainPalette.signal,
- decoration: InputDecoration(
- isDense: true,
- border: InputBorder.none,
- hintText: 'Ask your brain…',
- hintStyle: BrainType.body
- .copyWith(color: BrainPalette.textMuted),
+ child: Actions(
+ actions: {
+ _SubmitIntent: CallbackAction<_SubmitIntent>(
+ onInvoke: (_) {
+ unawaited(onSubmit());
+ return null;
+ },
+ ),
+ },
+ child: TextField(
+ key: const Key('chat_composer'),
+ controller: controller,
+ focusNode: focusNode,
+ enabled: enabled,
+ autofocus: true,
+ maxLines: 4,
+ minLines: 1,
+ style: BrainType.body,
+ cursorColor: BrainPalette.signal,
+ decoration: InputDecoration(
+ isDense: true,
+ border: InputBorder.none,
+ contentPadding: const EdgeInsets.symmetric(
+ vertical: 13,
+ ),
+ hintText: 'Ask your brain…',
+ hintStyle: BrainType.body.copyWith(
+ color: BrainPalette.textMuted,
+ ),
+ ),
),
),
),
),
- ),
- const SizedBox(width: 12),
- TextButton(
- key: const Key('chat_send'),
- onPressed: enabled ? () => unawaited(onSubmit()) : null,
- style: TextButton.styleFrom(
- foregroundColor: BrainPalette.signal,
- padding:
- const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ const SizedBox(width: 8),
+ IconButton(
+ key: const Key('chat_send'),
+ onPressed: enabled ? () => unawaited(onSubmit()) : null,
+ tooltip: 'Send',
+ color: BrainPalette.signal,
+ disabledColor: BrainPalette.textFaint,
+ icon: const Icon(Icons.arrow_upward_rounded),
),
- child: const Text('Send', style: BrainType.meta),
- ),
- ],
+ ],
+ ),
),
),
),
diff --git a/clients/digitalbrain_flutter/shell/lib/main.dart b/clients/digitalbrain_flutter/shell/lib/main.dart
index c95e2f64..ade58424 100644
--- a/clients/digitalbrain_flutter/shell/lib/main.dart
+++ b/clients/digitalbrain_flutter/shell/lib/main.dart
@@ -28,4 +28,4 @@ void main() {
: (text) => edge.sendMessage(chatName: chat, text: text),
),
);
-}
\ No newline at end of file
+}
diff --git a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart
index 7112ec2c..84584ac5 100644
--- a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart
+++ b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart
@@ -6,13 +6,96 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
ChatTurnEvent _turn(int sequence, bool fromUser, String text) => ChatTurnEvent(
- sequence: sequence,
- fromUser: fromUser,
- text: text,
- commandId: 'c$sequence',
- );
+ sequence: sequence,
+ fromUser: fromUser,
+ text: text,
+ commandId: 'c$sequence',
+);
void main() {
+ testWidgets('the workspace exposes Chat, Activity, and Brain destinations', (
+ tester,
+ ) async {
+ await tester.pumpWidget(const BrainChatApp(chatName: 'main'));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('destination_chat')), findsOneWidget);
+ expect(find.byKey(const Key('destination_activity')), findsOneWidget);
+ expect(find.byKey(const Key('destination_brain')), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('destination_activity')));
+ await tester.pumpAndSettle();
+ expect(find.byKey(const Key('activity_screen')), findsOneWidget);
+ expect(find.text('No activity yet.'), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+ expect(find.byKey(const Key('brain_screen')), findsOneWidget);
+ expect(find.text('General assistant'), findsOneWidget);
+ expect(
+ find.text('Gmail message → Salesforce Account description'),
+ findsOneWidget,
+ );
+ expect(find.text('Approval required'), findsOneWidget);
+ });
+
+ testWidgets('activity shows journal facts without message content', (
+ tester,
+ ) async {
+ final turns = StreamController();
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}),
+ );
+
+ turns.add(_turn(1, true, 'private customer message'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_activity')));
+ await tester.pumpAndSettle();
+
+ expect(find.text('UserMessaged'), findsOneWidget);
+ expect(find.text('sequence 001'), findsOneWidget);
+ expect(find.text('command c1'), findsOneWidget);
+ expect(find.text('private customer message'), findsNothing);
+ });
+
+ testWidgets('the shared event projection survives destination changes', (
+ tester,
+ ) async {
+ final turns = StreamController();
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}),
+ );
+
+ await tester.tap(find.byKey(const Key('destination_activity')));
+ await tester.pumpAndSettle();
+ turns.add(_turn(9, false, 'arrived while activity was open'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('AssistantResponded'), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('destination_chat')));
+ await tester.pumpAndSettle();
+ expect(find.text('arrived while activity was open'), findsOneWidget);
+ expect(find.text('009'), findsOneWidget);
+ });
+
+ testWidgets('narrow windows use bottom navigation', (tester) async {
+ tester.view.physicalSize = const Size(600, 800);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ await tester.pumpWidget(const BrainChatApp(chatName: 'main'));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(NavigationBar), findsOneWidget);
+ expect(find.byType(NavigationRail), findsNothing);
+ });
+
testWidgets('an empty conversation invites the owner to act', (tester) async {
await tester.pumpWidget(const BrainChatApp(chatName: 'main'));
await tester.pump();
@@ -22,8 +105,9 @@ void main() {
expect(find.byKey(const Key('chat_journal')), findsNothing);
});
- testWidgets('each turn carries its journal sequence and speaker',
- (tester) async {
+ testWidgets('each turn carries its journal sequence and speaker', (
+ tester,
+ ) async {
final turns = StreamController();
addTearDown(turns.close);
@@ -60,8 +144,9 @@ void main() {
expect(find.text('only once'), findsOneWidget);
});
- testWidgets('sending hands the text to the edge and awaits the brain',
- (tester) async {
+ testWidgets('sending hands the text to the edge and awaits the brain', (
+ tester,
+ ) async {
final sent = [];
final turns = StreamController();
addTearDown(turns.close);
@@ -91,15 +176,16 @@ void main() {
expect(find.text('Done.'), findsOneWidget);
});
- testWidgets('a disconnected edge disables sending and says so',
- (tester) async {
+ testWidgets('a disconnected edge disables sending and says so', (
+ tester,
+ ) async {
await tester.pumpWidget(
const BrainChatApp(chatName: 'main', statusMessage: 'no edge'),
);
await tester.pump();
expect(find.text('not connected'), findsOneWidget);
- final send = tester.widget(find.byKey(const Key('chat_send')));
+ final send = tester.widget(find.byKey(const Key('chat_send')));
expect(send.onPressed, isNull);
});
}
From 9334a152bc836e4ab4c339fabfbd19bf445027fb Mon Sep 17 00:00:00 2001
From: Vladyslav Horbachov <0965142838gvs@gmail.com>
Date: Tue, 28 Jul 2026 08:11:21 +0200
Subject: [PATCH 07/20] feat: render live brain topology
---
CLAUDE.md | 4 +-
README.md | 6 +-
.../bin/digitalbrain_host.dart | 3 +-
.../lib/digitalbrain_flutter.dart | 18 +-
.../lib/src/edge_client.dart | 38 +-
.../lib/src/shell_surface.dart | 4 +-
.../lib/src/sse_frames.dart | 4 +-
.../shell/lib/brain_screen.dart | 333 ++++++++++++--
.../shell/lib/brain_topology_canvas.dart | 420 ++++++++++++++++++
.../shell/lib/chat_screen.dart | 42 +-
.../digitalbrain_flutter/shell/lib/main.dart | 1 +
.../shell/test/chat_screen_test.dart | 132 ++++++
.../test/edge_client_test.dart | 88 +++-
.../test/shell_surface_test.dart | 102 +++--
.../lib/src/ui_edge_models.dart | 89 +++-
.../test/ui_edge_models_test.dart | 64 ++-
scripts/verify-product.ps1 | 58 +++
.../FlutterHostingProjectionSupport.cs | 29 +-
.../Hosting/FlutterHostingUIEdgeContracts.cs | 14 +-
.../Hosting/HostingProjectionContracts.cs | 25 +-
.../DigitalBrainHostingExtensions.cs | 25 +-
...talBrain.Modules.Flutter.Http.Tests.csproj | 1 +
.../UIEdgeRoundTrip.cs | 77 ++++
.../UIEdgeSse.cs | 66 +++
.../UIEdgeVocabulary.cs | 1 +
.../UIFixture.cs | 8 +
.../BrainEndpoints.cs | 23 +
.../BrainTopologyReader.cs | 52 +++
.../BrainTopologySnapshot.cs | 14 +
.../ChatEventFeed.cs | 14 +-
.../ChatTurnEvent.cs | 7 +-
.../UIEdgeContract.cs | 2 +
.../UIEdgeServices.cs | 1 +
.../UIHost.cs | 1 +
34 files changed, 1620 insertions(+), 146 deletions(-)
create mode 100644 clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart
create mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs
create mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs
create mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologySnapshot.cs
diff --git a/CLAUDE.md b/CLAUDE.md
index a0241e77..a45d66a0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -84,8 +84,8 @@ causal facts only — never arguments, prompts or secrets. Telemetry tags follow
`scripts/verify-product.ps1` is the one-command live oracle. It builds Release, waits for the product
resources, checks the exact MCP catalog, drives a real Gemma4 turn, confirms the durable transcript
-and correlation, checks telemetry content hygiene, and stops the AppHost unless `-KeepRunning` is
-specified.
+and correlation, confirms the configured modules and active chat neuron through the UI topology,
+checks telemetry content hygiene, and stops the AppHost unless `-KeepRunning` is specified.
## Gates
diff --git a/README.md b/README.md
index abc13fdf..e8a1b92d 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ The plan of record. Nothing is shipped unless it says Built.
| Neuron/synapse foundation, owner-scoped client, module activation, AppHost composition, testing path | **Built** |
| Typed AI, Tasks, Google, Salesforce, Chat, Flutter, Quickstart families | **Built** |
| Flutter vertical — shell/scene vocabulary, UI HTTP/SSE edge, `WithUIEdge`/`WithFlutterHost`, headless Dart host, Windows chrome | **Built** |
-| Product shell — responsive Chat, content-safe Activity, capability-accurate Brain surfaces | **Built** |
+| Product shell — responsive Chat, content-safe Activity, live 3D-projected Brain topology, pulses and inspector | **Built** |
| Product MCP surface — durable chat send/read, neuron journal observation, active-neuron discovery | **Built** |
| Time — durable one-shot `ICountdown` and its recovery tests | **Built** |
| Time — reminders, recurring interval/calendar scheduling, DST | Designed |
@@ -73,8 +73,8 @@ aspire run
```
[scripts/verify-product.ps1](scripts/verify-product.ps1) performs the Release build and a live,
-self-cleaning Aspire proof across resource health, MCP discovery, Gemma4 chat, durable journals, and
-OpenTelemetry:
+self-cleaning Aspire proof across resource health, MCP discovery, Gemma4 chat, durable journals,
+live UI topology, and OpenTelemetry:
```powershell
./scripts/verify-product.ps1
diff --git a/clients/digitalbrain_flutter/bin/digitalbrain_host.dart b/clients/digitalbrain_flutter/bin/digitalbrain_host.dart
index 329a4a6d..97fb459e 100644
--- a/clients/digitalbrain_flutter/bin/digitalbrain_host.dart
+++ b/clients/digitalbrain_flutter/bin/digitalbrain_host.dart
@@ -6,7 +6,8 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
Future main(List args) async {
final shell = _arg(args, '--shell') ?? DigitalBrainHostEnv.resolveShell();
final open = _arg(args, '--open');
- final reconnectSeconds = int.tryParse(_arg(args, '--reconnect-seconds') ?? '') ?? 2;
+ final reconnectSeconds =
+ int.tryParse(_arg(args, '--reconnect-seconds') ?? '') ?? 2;
final client = DigitalBrainUiEdgeClient.fromEnvironment();
final surface = ShellSurfaceController();
diff --git a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart
index eae8b094..13c1e5aa 100644
--- a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart
+++ b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart
@@ -1,12 +1,16 @@
-export 'package:digitalbrain_wire/digitalbrain_wire.dart' show
- ActivateControlRequest,
- ChatTurnEvent,
- OpenSceneRequest,
- SceneOpenedEvent,
- SendMessageRequest;
+export 'package:digitalbrain_wire/digitalbrain_wire.dart'
+ show
+ ActivateControlRequest,
+ BrainModule,
+ BrainNeuron,
+ BrainTopologySnapshot,
+ ChatTurnEvent,
+ OpenSceneRequest,
+ SceneOpenedEvent,
+ SendMessageRequest;
export 'src/edge_client.dart';
export 'src/host_environment.dart';
export 'src/shell_surface.dart';
export 'src/sse_chat_frames.dart';
-export 'src/sse_frames.dart';
\ No newline at end of file
+export 'src/sse_frames.dart';
diff --git a/clients/digitalbrain_flutter/lib/src/edge_client.dart b/clients/digitalbrain_flutter/lib/src/edge_client.dart
index 4c36e21d..31a3ac2b 100644
--- a/clients/digitalbrain_flutter/lib/src/edge_client.dart
+++ b/clients/digitalbrain_flutter/lib/src/edge_client.dart
@@ -8,11 +8,9 @@ import 'sse_chat_frames.dart';
import 'sse_frames.dart';
final class DigitalBrainUiEdgeClient {
- DigitalBrainUiEdgeClient({
- required this.baseUri,
- http.Client? httpClient,
- }) : _http = httpClient ?? http.Client(),
- _ownsClient = httpClient == null;
+ DigitalBrainUiEdgeClient({required this.baseUri, http.Client? httpClient})
+ : _http = httpClient ?? http.Client(),
+ _ownsClient = httpClient == null;
factory DigitalBrainUiEdgeClient.fromEnvironment({
http.Client? httpClient,
@@ -148,6 +146,36 @@ final class DigitalBrainUiEdgeClient {
}
}
+ Future readBrainTopology() async {
+ final uri = baseUri.replace(path: '/brain/topology');
+ final response = await _http.get(uri);
+ if (response.statusCode != 200) {
+ throw StateError(
+ 'brain-topology failed: ${response.statusCode} ${response.body}',
+ );
+ }
+
+ final decoded = jsonDecode(response.body);
+ if (decoded is! Map) {
+ throw const FormatException('brain-topology response is not an object');
+ }
+
+ return BrainTopologySnapshot.fromJson(Map.from(decoded));
+ }
+
+ Stream watchBrainTopology({
+ Duration pollInterval = const Duration(seconds: 2),
+ }) async* {
+ while (true) {
+ try {
+ yield await readBrainTopology();
+ } on Object catch (error, stackTrace) {
+ yield* Stream.error(error, stackTrace);
+ }
+ await Future.delayed(pollInterval);
+ }
+ }
+
void close() {
if (_ownsClient) {
_http.close();
diff --git a/clients/digitalbrain_flutter/lib/src/shell_surface.dart b/clients/digitalbrain_flutter/lib/src/shell_surface.dart
index 6b81ebb6..d1cacfa6 100644
--- a/clients/digitalbrain_flutter/lib/src/shell_surface.dart
+++ b/clients/digitalbrain_flutter/lib/src/shell_surface.dart
@@ -26,7 +26,9 @@ final class ShellSurfaceController {
title: event.title,
sequence: event.sequence,
);
- final index = _scenes.indexWhere((scene) => scene.sceneKey == view.sceneKey);
+ final index = _scenes.indexWhere(
+ (scene) => scene.sceneKey == view.sceneKey,
+ );
if (index >= 0) {
_scenes[index] = view;
} else {
diff --git a/clients/digitalbrain_flutter/lib/src/sse_frames.dart b/clients/digitalbrain_flutter/lib/src/sse_frames.dart
index 88273729..d1123940 100644
--- a/clients/digitalbrain_flutter/lib/src/sse_frames.dart
+++ b/clients/digitalbrain_flutter/lib/src/sse_frames.dart
@@ -55,9 +55,7 @@ final class SseSceneOpenedParser {
if (decoded is! Map) {
return null;
}
- return SceneOpenedEvent.fromJson(
- Map.from(decoded),
- );
+ return SceneOpenedEvent.fromJson(Map.from(decoded));
} on FormatException {
return null;
} on TypeError {
diff --git a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart
index c5256634..8b227f3d 100644
--- a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart
+++ b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart
@@ -2,23 +2,57 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
import 'package:flutter/material.dart';
import 'brain_theme.dart';
+import 'brain_topology_canvas.dart';
-final class BrainScreen extends StatelessWidget {
+final class BrainScreen extends StatefulWidget {
const BrainScreen({
super.key,
required this.chatName,
required this.turns,
+ this.topology,
this.statusMessage,
});
final String chatName;
final List turns;
+ final BrainTopologySnapshot? topology;
final String? statusMessage;
+ @override
+ State createState() => _BrainScreenState();
+}
+
+final class _BrainScreenState extends State {
+ BrainTopologySelection? _selection;
+
+ @override
+ void initState() {
+ super.initState();
+ if (widget.turns.isNotEmpty) {
+ _selection = BrainPulseSelection(widget.turns.last);
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant BrainScreen oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.turns.isNotEmpty &&
+ (oldWidget.turns.isEmpty ||
+ widget.turns.last.sequence != oldWidget.turns.last.sequence ||
+ widget.turns.last.correlationId !=
+ oldWidget.turns.last.correlationId)) {
+ setState(() => _selection = BrainPulseSelection(widget.turns.last));
+ }
+ }
+
@override
Widget build(BuildContext context) {
- final connected = statusMessage == null || statusMessage!.isEmpty;
- final lastSequence = turns.isEmpty ? '—' : '${turns.last.sequence}';
+ final connected =
+ widget.statusMessage == null || widget.statusMessage!.isEmpty;
+ final lastSequence = widget.turns.isEmpty
+ ? '—'
+ : '${widget.turns.last.sequence}';
+ final pulse = widget.turns.lastOrNull;
return ColoredBox(
key: const Key('brain_screen'),
@@ -26,14 +60,14 @@ final class BrainScreen extends StatelessWidget {
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 980),
+ constraints: const BoxConstraints(maxWidth: 1120),
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
children: [
const Text('Brain', style: BrainType.heading),
const SizedBox(height: 8),
const Text(
- 'What this DigitalBrain can do, and what remains under your control.',
+ 'Live modules, owner-scoped neurons, and durable causal pulses.',
style: BrainType.bodyMuted,
),
const SizedBox(height: 26),
@@ -48,10 +82,25 @@ final class BrainScreen extends StatelessWidget {
? BrainPalette.success
: BrainPalette.signal,
),
- _MetricCard(label: 'Conversation', value: chatName),
+ _MetricCard(
+ label: 'Modules',
+ value: '${widget.topology?.modules.length ?? 0}',
+ ),
+ _MetricCard(
+ label: 'Active neurons',
+ value: '${widget.topology?.neurons.length ?? 0}',
+ ),
_MetricCard(label: 'Last sequence', value: lastSequence),
],
),
+ const SizedBox(height: 24),
+ _TopologyPanel(
+ topology: widget.topology,
+ pulse: pulse,
+ selection: _selection,
+ onSelected: (selection) =>
+ setState(() => _selection = selection),
+ ),
const SizedBox(height: 30),
const _SectionLabel('CAPABILITIES'),
const SizedBox(height: 12),
@@ -75,7 +124,7 @@ final class BrainScreen extends StatelessWidget {
const _BoundaryCard(),
if (!connected) ...[
const SizedBox(height: 20),
- _ConnectionNotice(message: statusMessage!),
+ _ConnectionNotice(message: widget.statusMessage!),
],
],
),
@@ -85,6 +134,240 @@ final class BrainScreen extends StatelessWidget {
}
}
+final class _TopologyPanel extends StatelessWidget {
+ const _TopologyPanel({
+ required this.topology,
+ required this.pulse,
+ required this.selection,
+ required this.onSelected,
+ });
+
+ final BrainTopologySnapshot? topology;
+ final ChatTurnEvent? pulse;
+ final BrainTopologySelection? selection;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ final snapshot = topology;
+ if (snapshot == null) {
+ return Container(
+ height: 260,
+ decoration: _panelDecoration(),
+ child: const Center(
+ child: Text('Waiting for live topology…', style: BrainType.bodyMuted),
+ ),
+ );
+ }
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final wide = constraints.maxWidth >= 860;
+ final canvas = Container(
+ height: 430,
+ decoration: _panelDecoration(),
+ clipBehavior: Clip.antiAlias,
+ child: BrainTopologyCanvas(
+ topology: snapshot,
+ pulse: pulse,
+ onSelected: onSelected,
+ ),
+ );
+ final explorer = _TopologyExplorer(
+ topology: snapshot,
+ selection: selection,
+ onSelected: onSelected,
+ );
+
+ if (wide) {
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(flex: 3, child: canvas),
+ const SizedBox(width: 14),
+ SizedBox(width: 320, child: explorer),
+ ],
+ );
+ }
+
+ return Column(children: [canvas, const SizedBox(height: 14), explorer]);
+ },
+ );
+ }
+}
+
+final class _TopologyExplorer extends StatelessWidget {
+ const _TopologyExplorer({
+ required this.topology,
+ required this.selection,
+ required this.onSelected,
+ });
+
+ final BrainTopologySnapshot topology;
+ final BrainTopologySelection? selection;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ key: const Key('brain_inspector'),
+ constraints: const BoxConstraints(minHeight: 430),
+ padding: const EdgeInsets.all(18),
+ decoration: _panelDecoration(),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('INSPECTOR', style: BrainType.meta),
+ const SizedBox(height: 14),
+ _SelectionDetails(selection: selection),
+ const SizedBox(height: 20),
+ const Text('MODULES', style: BrainType.meta),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 7,
+ runSpacing: 7,
+ children: [
+ for (var index = 0; index < topology.modules.length; index++)
+ ActionChip(
+ key: Key('topology_module_$index'),
+ label: Text(_moduleLabel(topology.modules[index].id)),
+ onPressed: () =>
+ onSelected(BrainModuleSelection(topology.modules[index])),
+ ),
+ ],
+ ),
+ const SizedBox(height: 18),
+ const Text('ACTIVE NEURONS', style: BrainType.meta),
+ const SizedBox(height: 8),
+ for (var index = 0; index < topology.neurons.length; index++)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 6),
+ child: TextButton(
+ key: Key('topology_neuron_$index'),
+ onPressed: () =>
+ onSelected(BrainNeuronSelection(topology.neurons[index])),
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(
+ topology.neurons[index].id,
+ overflow: TextOverflow.ellipsis,
+ style: BrainType.meta,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+final class _SelectionDetails extends StatelessWidget {
+ const _SelectionDetails({required this.selection});
+
+ final BrainTopologySelection? selection;
+
+ @override
+ Widget build(BuildContext context) {
+ return switch (selection) {
+ BrainPulseSelection(:final turn) => _PulseDetails(turn: turn),
+ BrainNeuronSelection(:final neuron) => _NeuronDetails(neuron: neuron),
+ BrainModuleSelection(:final module) => _ModuleDetails(module: module),
+ null => const Text(
+ 'Select a module or neuron. New chat turns open their causal pulse automatically.',
+ style: BrainType.bodyMuted,
+ ),
+ };
+ }
+}
+
+final class _PulseDetails extends StatelessWidget {
+ const _PulseDetails({required this.turn});
+
+ final ChatTurnEvent turn;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ key: const Key('brain_pulse_details'),
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(turn.synapse, style: BrainType.cardTitle),
+ const SizedBox(height: 10),
+ _InspectorField(label: 'neuron', value: turn.neuronId),
+ _InspectorField(label: 'caller', value: turn.caller),
+ _InspectorField(label: 'correlation', value: turn.correlationId),
+ _InspectorField(label: 'command', value: turn.commandId),
+ _InspectorField(label: 'sequence', value: '${turn.sequence}'),
+ _InspectorField(
+ label: 'timestamp',
+ value: turn.timestamp.toIso8601String(),
+ ),
+ ],
+ );
+ }
+}
+
+final class _NeuronDetails extends StatelessWidget {
+ const _NeuronDetails({required this.neuron});
+
+ final BrainNeuron neuron;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(neuron.grainType, style: BrainType.cardTitle),
+ const SizedBox(height: 10),
+ _InspectorField(label: 'id', value: neuron.id),
+ _InspectorField(label: 'identity', value: neuron.identity),
+ _InspectorField(label: 'silo', value: neuron.silo),
+ ],
+ );
+ }
+}
+
+final class _ModuleDetails extends StatelessWidget {
+ const _ModuleDetails({required this.module});
+
+ final BrainModule module;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(_moduleLabel(module.id), style: BrainType.cardTitle),
+ const SizedBox(height: 10),
+ _InspectorField(label: 'module id', value: module.id),
+ ],
+ );
+ }
+}
+
+final class _InspectorField extends StatelessWidget {
+ const _InspectorField({required this.label, required this.value});
+
+ final String label;
+ final String value;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 7),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label, style: BrainType.meta),
+ const SizedBox(height: 2),
+ SelectableText(value, style: BrainType.metaStrong),
+ ],
+ ),
+ );
+ }
+}
+
final class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.label,
@@ -101,11 +384,7 @@ final class _MetricCard extends StatelessWidget {
return Container(
width: 190,
padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: BrainPalette.surfaceRaised,
- borderRadius: BorderRadius.circular(14),
- border: Border.all(color: BrainPalette.line),
- ),
+ decoration: _panelDecoration(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -129,9 +408,7 @@ final class _SectionLabel extends StatelessWidget {
final String text;
@override
- Widget build(BuildContext context) {
- return Text(text, style: BrainType.meta);
- }
+ Widget build(BuildContext context) => Text(text, style: BrainType.meta);
}
final class _CapabilityCard extends StatelessWidget {
@@ -151,11 +428,7 @@ final class _CapabilityCard extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
- decoration: BoxDecoration(
- color: BrainPalette.surfaceRaised,
- borderRadius: BorderRadius.circular(14),
- border: Border.all(color: BrainPalette.line),
- ),
+ decoration: _panelDecoration(),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -219,11 +492,7 @@ final class _BoundaryCard extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
- decoration: BoxDecoration(
- color: BrainPalette.surfaceSunken,
- borderRadius: BorderRadius.circular(14),
- border: Border.all(color: BrainPalette.line),
- ),
+ decoration: _panelDecoration(color: BrainPalette.surfaceSunken),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -277,3 +546,17 @@ final class _ConnectionNotice extends StatelessWidget {
);
}
}
+
+BoxDecoration _panelDecoration({Color color = BrainPalette.surfaceRaised}) =>
+ BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: BrainPalette.line),
+ );
+
+String _moduleLabel(String id) {
+ final type = id.split('.').last;
+ return type.endsWith('Module')
+ ? type.substring(0, type.length - 'Module'.length)
+ : type;
+}
diff --git a/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart
new file mode 100644
index 00000000..fe0349e2
--- /dev/null
+++ b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart
@@ -0,0 +1,420 @@
+import 'dart:math' as math;
+
+import 'package:digitalbrain_flutter/digitalbrain_flutter.dart';
+import 'package:flutter/material.dart';
+
+import 'brain_theme.dart';
+
+sealed class BrainTopologySelection {
+ const BrainTopologySelection();
+}
+
+final class BrainModuleSelection extends BrainTopologySelection {
+ const BrainModuleSelection(this.module);
+
+ final BrainModule module;
+}
+
+final class BrainNeuronSelection extends BrainTopologySelection {
+ const BrainNeuronSelection(this.neuron);
+
+ final BrainNeuron neuron;
+}
+
+final class BrainPulseSelection extends BrainTopologySelection {
+ const BrainPulseSelection(this.turn);
+
+ final ChatTurnEvent turn;
+}
+
+final class BrainTopologyCanvas extends StatefulWidget {
+ const BrainTopologyCanvas({
+ super.key,
+ required this.topology,
+ required this.onSelected,
+ this.pulse,
+ });
+
+ final BrainTopologySnapshot topology;
+ final ChatTurnEvent? pulse;
+ final ValueChanged onSelected;
+
+ @override
+ State createState() => _BrainTopologyCanvasState();
+}
+
+final class _BrainTopologyCanvasState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _pulse;
+ double _rotationX = -0.18;
+ double _rotationY = 0.42;
+
+ @override
+ void initState() {
+ super.initState();
+ _pulse = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 1100),
+ );
+ if (_hasPulseTarget(widget.topology, widget.pulse)) {
+ _pulse.forward();
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant BrainTopologyCanvas oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ final pulseChanged =
+ widget.pulse?.sequence != oldWidget.pulse?.sequence ||
+ widget.pulse?.correlationId != oldWidget.pulse?.correlationId;
+ final hadTarget = _hasPulseTarget(oldWidget.topology, oldWidget.pulse);
+ final hasTarget = _hasPulseTarget(widget.topology, widget.pulse);
+ if (!hasTarget) {
+ if (pulseChanged || hadTarget) {
+ _pulse.reset();
+ }
+ } else if (pulseChanged || !hadTarget) {
+ _pulse.forward(from: 0);
+ }
+ }
+
+ @override
+ void dispose() {
+ _pulse.dispose();
+ super.dispose();
+ }
+
+ void _rotate(DragUpdateDetails details) {
+ setState(() {
+ _rotationY += details.delta.dx * 0.008;
+ _rotationX = (_rotationX + details.delta.dy * 0.008).clamp(-1.0, 1.0);
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final disableAnimations = MediaQuery.disableAnimationsOf(context);
+ final hasPulseTarget = _hasPulseTarget(widget.topology, widget.pulse);
+
+ return Semantics(
+ key: const Key('brain_topology_canvas'),
+ label:
+ 'Interactive three-dimensional DigitalBrain topology. Drag to rotate; use the topology list to inspect accessible node details.',
+ image: true,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final size = Size(constraints.maxWidth, constraints.maxHeight);
+ return AnimatedBuilder(
+ animation: _pulse,
+ builder: (context, _) {
+ final projected = _projectTopology(
+ widget.topology,
+ size,
+ _rotationX,
+ _rotationY,
+ );
+ final pulseValue = disableAnimations ? 1.0 : _pulse.value;
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onPanUpdate: _rotate,
+ onTapUp: (details) {
+ final selected = _hitTest(projected, details.localPosition);
+ if (selected != null) {
+ widget.onSelected(selected.selection);
+ }
+ },
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ CustomPaint(
+ painter: _TopologyPainter(
+ nodes: projected,
+ pulse: widget.pulse,
+ pulseValue: pulseValue,
+ ),
+ ),
+ if (hasPulseTarget)
+ const IgnorePointer(key: Key('brain_pulse')),
+ ],
+ ),
+ );
+ },
+ );
+ },
+ ),
+ );
+ }
+}
+
+bool _hasPulseTarget(BrainTopologySnapshot topology, ChatTurnEvent? pulse) =>
+ pulse != null &&
+ topology.neurons.any((neuron) => neuron.id == pulse.neuronId);
+
+final class _GraphNode {
+ const _GraphNode({
+ required this.id,
+ required this.label,
+ required this.module,
+ required this.selection,
+ required this.x,
+ required this.y,
+ required this.z,
+ });
+
+ final String id;
+ final String label;
+ final bool module;
+ final BrainTopologySelection selection;
+ final double x;
+ final double y;
+ final double z;
+}
+
+final class _ProjectedNode {
+ const _ProjectedNode({
+ required this.node,
+ required this.center,
+ required this.radius,
+ required this.depth,
+ });
+
+ final _GraphNode node;
+ final Offset center;
+ final double radius;
+ final double depth;
+
+ BrainTopologySelection get selection => node.selection;
+}
+
+List<_ProjectedNode> _projectTopology(
+ BrainTopologySnapshot topology,
+ Size size,
+ double rotationX,
+ double rotationY,
+) {
+ final graph = <_GraphNode>[
+ ..._placeModules(topology.modules),
+ ..._placeNeurons(topology.neurons),
+ ];
+ final base = math.min(size.width, size.height) * 0.36;
+ final center = Offset(size.width * 0.5, size.height * 0.51);
+ final cosY = math.cos(rotationY);
+ final sinY = math.sin(rotationY);
+ final cosX = math.cos(rotationX);
+ final sinX = math.sin(rotationX);
+
+ final projected = <_ProjectedNode>[];
+ for (final node in graph) {
+ final xY = node.x * cosY + node.z * sinY;
+ final zY = -node.x * sinY + node.z * cosY;
+ final yX = node.y * cosX - zY * sinX;
+ final zX = node.y * sinX + zY * cosX;
+ final perspective = 1.0 / (1.85 - zX * 0.36);
+ final radius = (node.module ? 10.0 : 6.0) * (0.72 + perspective);
+
+ projected.add(
+ _ProjectedNode(
+ node: node,
+ center: Offset(
+ center.dx + xY * base * perspective,
+ center.dy + yX * base * perspective,
+ ),
+ radius: radius,
+ depth: zX,
+ ),
+ );
+ }
+
+ projected.sort((a, b) => a.depth.compareTo(b.depth));
+ return projected;
+}
+
+Iterable<_GraphNode> _placeModules(List modules) sync* {
+ for (var index = 0; index < modules.length; index++) {
+ final position = _spherePosition(index, modules.length, 0.88, 0);
+ final module = modules[index];
+ yield _GraphNode(
+ id: module.id,
+ label: _moduleLabel(module.id),
+ module: true,
+ selection: BrainModuleSelection(module),
+ x: position.x,
+ y: position.y,
+ z: position.z,
+ );
+ }
+}
+
+Iterable<_GraphNode> _placeNeurons(List neurons) sync* {
+ for (var index = 0; index < neurons.length; index++) {
+ final position = _spherePosition(index, neurons.length, 0.62, 1.3);
+ final neuron = neurons[index];
+ yield _GraphNode(
+ id: neuron.id,
+ label: neuron.grainType,
+ module: false,
+ selection: BrainNeuronSelection(neuron),
+ x: position.x,
+ y: position.y,
+ z: position.z,
+ );
+ }
+}
+
+({double x, double y, double z}) _spherePosition(
+ int index,
+ int count,
+ double radius,
+ double phase,
+) {
+ if (count <= 1) {
+ return (x: 0, y: 0, z: radius);
+ }
+ final y = 1 - (2 * (index + 0.5) / count);
+ final ring = math.sqrt(math.max(0, 1 - y * y));
+ final theta = index * math.pi * (3 - math.sqrt(5)) + phase;
+ return (
+ x: math.cos(theta) * ring * radius,
+ y: y * radius,
+ z: math.sin(theta) * ring * radius,
+ );
+}
+
+_ProjectedNode? _hitTest(List<_ProjectedNode> nodes, Offset position) {
+ for (final node in nodes.reversed) {
+ if ((node.center - position).distance <= node.radius + 8) {
+ return node;
+ }
+ }
+ return null;
+}
+
+String _moduleLabel(String id) {
+ final type = id.split('.').last;
+ return type.endsWith('Module')
+ ? type.substring(0, type.length - 'Module'.length)
+ : type;
+}
+
+final class _TopologyPainter extends CustomPainter {
+ const _TopologyPainter({
+ required this.nodes,
+ required this.pulse,
+ required this.pulseValue,
+ });
+
+ final List<_ProjectedNode> nodes;
+ final ChatTurnEvent? pulse;
+ final double pulseValue;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final center = Offset(size.width * 0.5, size.height * 0.51);
+ final hull = Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 1
+ ..color = BrainPalette.lineStrong.withValues(alpha: 0.55);
+ final radius = math.min(size.width, size.height) * 0.34;
+ canvas.drawOval(
+ Rect.fromCenter(
+ center: center,
+ width: radius * 2.15,
+ height: radius * 1.55,
+ ),
+ hull,
+ );
+ canvas.drawOval(
+ Rect.fromCenter(
+ center: center,
+ width: radius * 1.35,
+ height: radius * 2.05,
+ ),
+ hull..color = BrainPalette.line.withValues(alpha: 0.5),
+ );
+
+ final pulseTarget = pulse == null
+ ? null
+ : nodes.where((node) => node.node.id == pulse!.neuronId).firstOrNull;
+ final pulseSource = pulse == null
+ ? null
+ : nodes.where((node) => node.node.id == pulse!.caller).firstOrNull;
+ if (pulseTarget != null) {
+ final wave = math.sin(pulseValue * math.pi).abs();
+ final path = Path()
+ ..moveTo(
+ (pulseSource?.center ?? center).dx,
+ (pulseSource?.center ?? center).dy,
+ )
+ ..quadraticBezierTo(
+ center.dx,
+ center.dy - radius * 0.45,
+ pulseTarget.center.dx,
+ pulseTarget.center.dy,
+ );
+ canvas.drawPath(
+ path,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 2.4
+ ..color = BrainPalette.signal.withValues(alpha: 0.25 + wave * 0.7),
+ );
+ canvas.drawCircle(
+ pulseTarget.center,
+ pulseTarget.radius + 8 + wave * 16,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 2
+ ..color = BrainPalette.signal.withValues(alpha: 0.7 - wave * 0.3),
+ );
+ }
+
+ for (final projected in nodes) {
+ final node = projected.node;
+ final color = node.module ? BrainPalette.signal : BrainPalette.owner;
+ final depthAlpha = (0.5 + (projected.depth + 1) * 0.22).clamp(0.35, 1.0);
+ canvas.drawCircle(
+ projected.center,
+ projected.radius * 1.8,
+ Paint()
+ ..color = color.withValues(alpha: 0.06 * depthAlpha)
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
+ );
+ canvas.drawCircle(
+ projected.center,
+ projected.radius,
+ Paint()..color = color.withValues(alpha: depthAlpha),
+ );
+ canvas.drawCircle(
+ projected.center,
+ projected.radius,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 1
+ ..color = BrainPalette.textPrimary.withValues(alpha: 0.3),
+ );
+
+ if (node.module || node.id == pulse?.neuronId) {
+ final text = TextPainter(
+ text: TextSpan(
+ text: node.label,
+ style: BrainType.meta.copyWith(
+ color: BrainPalette.textPrimary.withValues(alpha: depthAlpha),
+ ),
+ ),
+ textDirection: TextDirection.ltr,
+ )..layout(maxWidth: 120);
+ text.paint(
+ canvas,
+ projected.center + Offset(-text.width / 2, projected.radius + 7),
+ );
+ }
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant _TopologyPainter oldDelegate) =>
+ oldDelegate.nodes != nodes ||
+ oldDelegate.pulse != pulse ||
+ oldDelegate.pulseValue != pulseValue;
+}
diff --git a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart
index b9faa34b..3cf59c26 100644
--- a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart
+++ b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart
@@ -15,12 +15,14 @@ final class BrainChatApp extends StatelessWidget {
super.key,
required this.chatName,
this.turns,
+ this.topology,
this.onSend,
this.statusMessage,
});
final String chatName;
final Stream? turns;
+ final Stream? topology;
final SendMessage? onSend;
final String? statusMessage;
@@ -33,6 +35,7 @@ final class BrainChatApp extends StatelessWidget {
home: _BrainWorkspace(
chatName: chatName,
turns: turns,
+ topology: topology,
onSend: onSend,
statusMessage: statusMessage,
),
@@ -44,12 +47,14 @@ final class _BrainWorkspace extends StatefulWidget {
const _BrainWorkspace({
required this.chatName,
this.turns,
+ this.topology,
this.onSend,
this.statusMessage,
});
final String chatName;
final Stream? turns;
+ final Stream? topology;
final SendMessage? onSend;
final String? statusMessage;
@@ -63,13 +68,17 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> {
final _turns = [];
final _seen = {};
StreamSubscription? _subscription;
+ StreamSubscription? _topologySubscription;
+ BrainTopologySnapshot? _topology;
int _destination = 0;
- String? _streamFailure;
+ String? _turnFailure;
+ String? _topologyFailure;
@override
void initState() {
super.initState();
_listen(widget.turns);
+ _listenTopology(widget.topology);
}
@override
@@ -83,10 +92,32 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> {
unawaited(_subscription?.cancel());
_listen(widget.turns);
}
+ if (!identical(oldWidget.topology, widget.topology)) {
+ unawaited(_topologySubscription?.cancel());
+ _listenTopology(widget.topology);
+ }
+ }
+
+ void _listenTopology(Stream? topology) {
+ _topologySubscription = topology?.listen(
+ (snapshot) {
+ if (mounted) {
+ setState(() {
+ _topology = snapshot;
+ _topologyFailure = null;
+ });
+ }
+ },
+ onError: (Object error) {
+ if (mounted) {
+ setState(() => _topologyFailure = '$error');
+ }
+ },
+ );
}
void _listen(Stream? turns) {
- _streamFailure = null;
+ _turnFailure = null;
_subscription = turns?.listen(
(turn) {
if (!mounted || !_seen.add(turn.sequence)) {
@@ -99,13 +130,14 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> {
},
onError: (Object error) {
if (mounted) {
- setState(() => _streamFailure = '$error');
+ setState(() => _turnFailure = '$error');
}
},
);
}
- String? get _statusMessage => widget.statusMessage ?? _streamFailure;
+ String? get _statusMessage =>
+ widget.statusMessage ?? _turnFailure ?? _topologyFailure;
void _selectDestination(int index) {
if (_destination != index) {
@@ -116,6 +148,7 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> {
@override
void dispose() {
unawaited(_subscription?.cancel());
+ unawaited(_topologySubscription?.cancel());
super.dispose();
}
@@ -132,6 +165,7 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> {
BrainScreen(
chatName: widget.chatName,
turns: projectedTurns,
+ topology: _topology,
statusMessage: _statusMessage,
),
];
diff --git a/clients/digitalbrain_flutter/shell/lib/main.dart b/clients/digitalbrain_flutter/shell/lib/main.dart
index ade58424..c6f16780 100644
--- a/clients/digitalbrain_flutter/shell/lib/main.dart
+++ b/clients/digitalbrain_flutter/shell/lib/main.dart
@@ -23,6 +23,7 @@ void main() {
chatName: chat,
statusMessage: status,
turns: edge?.watchChatTurns(chatName: chat),
+ topology: edge?.watchBrainTopology(),
onSend: edge == null
? null
: (text) => edge.sendMessage(chatName: chat, text: text),
diff --git a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart
index 84584ac5..95ce09cd 100644
--- a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart
+++ b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart
@@ -10,6 +10,33 @@ ChatTurnEvent _turn(int sequence, bool fromUser, String text) => ChatTurnEvent(
fromUser: fromUser,
text: text,
commandId: 'c$sequence',
+ synapse: fromUser ? 'UserMessaged' : 'AssistantResponded',
+ neuronId: 'chat:owner/main',
+ caller: 'session:owner/session',
+ correlationId: 'correlation-$sequence',
+ timestamp: DateTime.utc(2026, 7, 28, 8, 0, sequence),
+);
+
+BrainTopologySnapshot _topology() => BrainTopologySnapshot(
+ modules: const [
+ BrainModule(id: 'DigitalBrain.Chat.ChatModule'),
+ BrainModule(id: 'DigitalBrain.AI.AIModule'),
+ ],
+ neurons: const [
+ BrainNeuron(
+ id: 'chat:owner/main',
+ grainType: 'chat',
+ identity: 'owner/main',
+ silo: 'silo-1',
+ ),
+ ],
+ observedAt: DateTime.utc(2026, 7, 28, 8),
+);
+
+BrainTopologySnapshot _topologyWithoutNeuron() => BrainTopologySnapshot(
+ modules: _topology().modules,
+ neurons: const [],
+ observedAt: DateTime.utc(2026, 7, 28, 8),
);
void main() {
@@ -31,6 +58,11 @@ void main() {
await tester.tap(find.byKey(const Key('destination_brain')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('brain_screen')), findsOneWidget);
+ await tester.scrollUntilVisible(
+ find.text('General assistant'),
+ 300,
+ scrollable: find.byType(Scrollable),
+ );
expect(find.text('General assistant'), findsOneWidget);
expect(
find.text('Gmail message → Salesforce Account description'),
@@ -96,6 +128,106 @@ void main() {
expect(find.byType(NavigationRail), findsNothing);
});
+ testWidgets('Brain renders live modules and active neurons', (tester) async {
+ final topology = StreamController();
+ addTearDown(topology.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', topology: topology.stream),
+ );
+ topology.add(_topology());
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget);
+ expect(find.byKey(const Key('topology_module_0')), findsOneWidget);
+ expect(find.byKey(const Key('topology_module_1')), findsOneWidget);
+ expect(find.text('Chat'), findsWidgets);
+ expect(find.text('AI'), findsOneWidget);
+ expect(find.text('chat:owner/main'), findsOneWidget);
+ });
+
+ testWidgets('Brain clears a transient topology failure after recovery', (
+ tester,
+ ) async {
+ final topology = StreamController();
+ addTearDown(topology.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(chatName: 'main', topology: topology.stream),
+ );
+ topology.addError(StateError('topology temporarily unavailable'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Offline'), findsOneWidget);
+
+ topology.add(_topology());
+ await tester.pumpAndSettle();
+
+ expect(find.text('Connected'), findsOneWidget);
+ });
+
+ testWidgets('a chat turn pulses Brain and opens correlation inspector', (
+ tester,
+ ) async {
+ final topology = StreamController();
+ final turns = StreamController();
+ addTearDown(topology.close);
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(
+ chatName: 'main',
+ topology: topology.stream,
+ turns: turns.stream,
+ ),
+ );
+ topology.add(_topology());
+ turns.add(_turn(9, true, 'private pulse content'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('brain_pulse')), findsOneWidget);
+ expect(find.byKey(const Key('brain_inspector')), findsOneWidget);
+ expect(find.text('correlation-9'), findsOneWidget);
+ expect(find.text('chat:owner/main'), findsWidgets);
+ expect(find.text('private pulse content'), findsNothing);
+ });
+
+ testWidgets('a pulse waits for its neuron to appear in live topology', (
+ tester,
+ ) async {
+ final topology = StreamController();
+ final turns = StreamController();
+ addTearDown(topology.close);
+ addTearDown(turns.close);
+
+ await tester.pumpWidget(
+ BrainChatApp(
+ chatName: 'main',
+ topology: topology.stream,
+ turns: turns.stream,
+ ),
+ );
+ topology.add(_topologyWithoutNeuron());
+ turns.add(_turn(12, false, 'not exposed by the topology view'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('destination_brain')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('brain_pulse')), findsNothing);
+
+ await tester.pump(const Duration(milliseconds: 1200));
+ topology.add(_topology());
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('brain_pulse')), findsOneWidget);
+ });
+
testWidgets('an empty conversation invites the owner to act', (tester) async {
await tester.pumpWidget(const BrainChatApp(chatName: 'main'));
await tester.pump();
diff --git a/clients/digitalbrain_flutter/test/edge_client_test.dart b/clients/digitalbrain_flutter/test/edge_client_test.dart
index e7c9f031..ee731504 100644
--- a/clients/digitalbrain_flutter/test/edge_client_test.dart
+++ b/clients/digitalbrain_flutter/test/edge_client_test.dart
@@ -16,11 +16,7 @@ void main() {
}),
);
- await client.openScene(
- shellName: 'desk',
- sceneKey: 'home',
- title: 'Home',
- );
+ await client.openScene(shellName: 'desk', sceneKey: 'home', title: 'Home');
expect(seen, isNotNull);
expect(seen!.method, 'POST');
@@ -153,4 +149,86 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c","
throwsA(isA()),
);
});
+
+ test(
+ 'watchBrainTopology polls live modules and neurons without restart',
+ () async {
+ var requestCount = 0;
+ final client = DigitalBrainUiEdgeClient(
+ baseUri: Uri.parse('http://ui.example:5080'),
+ httpClient: MockClient((request) async {
+ expect(request.method, 'GET');
+ expect(
+ request.url.toString(),
+ 'http://ui.example:5080/brain/topology',
+ );
+ requestCount++;
+ return http.Response(
+ jsonEncode({
+ 'modules': [
+ {'id': 'DigitalBrain.Chat.ChatModule'},
+ ],
+ 'neurons': [
+ if (requestCount > 1)
+ {
+ 'id': 'chat:owner/main',
+ 'grainType': 'chat',
+ 'identity': 'owner/main',
+ 'silo': 'silo-1',
+ },
+ ],
+ 'observedAt': '2026-07-28T08:00:00Z',
+ }),
+ 200,
+ headers: {'content-type': 'application/json'},
+ );
+ }),
+ );
+
+ final snapshots = await client
+ .watchBrainTopology(pollInterval: Duration.zero)
+ .take(2)
+ .toList();
+
+ expect(snapshots.first.neurons, isEmpty);
+ expect(snapshots.last.neurons.single.id, 'chat:owner/main');
+ },
+ );
+
+ test(
+ 'watchBrainTopology reports a transient failure and keeps polling',
+ () async {
+ var requestCount = 0;
+ final client = DigitalBrainUiEdgeClient(
+ baseUri: Uri.parse('http://ui.example:5080'),
+ httpClient: MockClient((request) async {
+ requestCount++;
+ if (requestCount == 1) {
+ return http.Response('temporarily unavailable', 503);
+ }
+ return http.Response(
+ jsonEncode({
+ 'modules': [
+ {'id': 'DigitalBrain.Chat.ChatModule'},
+ ],
+ 'neurons': const [],
+ 'observedAt': '2026-07-28T08:00:00Z',
+ }),
+ 200,
+ headers: {'content-type': 'application/json'},
+ );
+ }),
+ );
+ final errors =