Skip to content

[Blazor] Add shared agent and UI state - #68334

Merged
javiercn merged 9 commits into
mainfrom
javiercn-components-ai-08-shared-state
Aug 28, 2026
Merged

javiercn merged 9 commits into
mainfrom
javiercn-components-ai-08-shared-state

Conversation

@javiercn

@javiercn javiercn commented Aug 10, 2026 •

Copy link
Copy Markdown
Member

Overview

Position 8 in the native Components.AI stack tracked by #68340, this layer depends on #68333 and adds persistent conversation threads plus the canonical Shared State recipe dojo. Relative to parent javiercn-components-ai-07-agentic-generative-ui (913754f3988a8d4ebc6ddb29785a25bbe796c458), the stack-relative diff contains the product runtime contracts, the recipe scenario, focused tests, and its generated replay. The cross-cutting constraint is that product code remains provider/protocol-neutral while DojoClient keeps the real AGUIChatClient 0.0.5 -> HTTP/SSE -> AGUIDojoApi/AGUI.Server 0.0.5 path.

Design

The new product contract stores raw streamed chat updates rather than rendered UI blocks or AG-UI events. That keeps persistence owned by the application, lets restoration reuse the existing block/state mapping pipeline, and avoids coupling Components.AI to a transport protocol.

// src/Components/AI/src/Engine/IConversationThread.cs
// The thread owns persistence and service identity; Components.AI only defines lifecycle boundaries.
public interface IConversationThread
{
    string ThreadId { get; }
    bool IsStateful { get; }
    string? ConversationId { get; }

    void AppendMessages(IEnumerable<ChatMessage> messages);
    void AppendUpdate(ChatResponseUpdate update);
    void CompleteTurn();
    IReadOnlyList<ChatResponseUpdate> GetUpdates();
}
// src/Components/AI/src/Pipeline/UIAgentOptions.cs
// Opt-in preserves existing stateless behavior when no thread is configured.
public IConversationThread? Thread { get; set; }

// src/Components/AI/src/Engine/UIAgent.cs
// Replays committed updates through the same state mapper and content pipeline used live.
public Task<IReadOnlyList<ContentBlock>> RestoreAsync(
    CancellationToken cancellationToken = default);

// src/Components/AI/src/Engine/AgentContext.cs
// Reconstructs ConversationTurn instances for UI consumers without firing live-stream callbacks.
public Task RestoreAsync(CancellationToken cancellationToken = default);

The lifecycle has three equivalence classes, each represented once in the implementation and tests: a successful stream appends every request message and response update before committing atomically; a failed or cancelled partial stream never reaches CompleteTurn; restoration replays only committed updates into temporary history and a clean typed-state scope, then commits both together. A failed or cancelled restore leaves the previous history and state unchanged, while an empty thread clears both. For stateful providers, the thread-captured ConversationId is forwarded on the next request; stateless providers continue to receive the full reconstructed history.

Implementation

UIAgent starts a pending turn before model invocation, forwards service conversation identity when present, and deliberately commits only after streaming, mapping, finalization, and history reconstruction all complete. The placement of CompleteTurn is the failure-safety guarantee: an exception or cancellation inside the loop leaves no committed partial turn.

// src/Components/AI/src/Engine/UIAgent.cs
var thread = _options.Thread;
foreach (var message in messages)
{
    _history.Add(message);
}

thread?.AppendMessages(messages);

var chatOptions = BuildChatOptions();
if (thread is { IsStateful: true, ConversationId: not null })
{
    // A service-managed conversation resumes by ID; other threads retain full local history.
    chatOptions = chatOptions?.Clone() ?? new ChatOptions();
    chatOptions.ConversationId = thread.ConversationId;
}

await foreach (var update in _chatClient.GetStreamingResponseAsync(
    _history, chatOptions, cancellationToken).ConfigureAwait(false))
{
    thread?.AppendUpdate(update);

    // Live and restored updates share the same typed-state mapper and block pipeline.
    var processUpdate = ApplyStateMapper(update);
    if (processUpdate.Contents.Count == 0 && update.Contents.Count > 0)
    {
        continue;
    }

    assistantUpdates.Add(processUpdate);
    await foreach (var block in pipeline.Process(processUpdate, cancellationToken).ConfigureAwait(false))
    {
        yield return block;
    }
}

// This line is unreachable for a failed/cancelled stream, so partial updates are not persisted.
thread?.CompleteTurn();

Restoration treats both user and tool messages as request boundaries, groups assistant updates back into messages, and feeds every assistant update through ApplyStateMapper. History is rebuilt separately while typed state suppresses intermediate notifications and resets to a new TState before replay, which lets state deltas read the restored value rather than stale live state. AgentContext.RestoreAsync rejects active turns and replaces turns, status, error, retry state, and the previous cancellation source only after agent restoration succeeds.

The dojo is the protocol adapter. Local editor changes replace the same typed RecipeState read by RunAgentInput.State; AG-UI state snapshots deserialize back into that state. A stable thread ID is supplied on every request, while the product runtime sees only IConversationThread, ChatOptions, and ChatResponseUpdate.

// src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/SharedStateScenario.razor
_thread = new SharedStateConversationThread(Guid.NewGuid().ToString("N"));

return new UIAgent<RecipeState>(ChatClient, options =>
{
    options.Thread = _thread;
    options.ChatOptions = new ChatOptions
    {
        RawRepresentationFactory = _ => new RunAgentInput
        {
            ThreadId = _thread.ThreadId,
            // Each turn serializes the latest user-or-agent-edited recipe, not the initial copy.
            State = JsonSerializer.SerializeToElement(_agent.State.Value, _jsonOptions),
        },
    };
    options.StateMapper = context =>
    {
        if (context.Update.RawRepresentation is not StateSnapshotEvent snapshot)
        {
            return;
        }

        var state = snapshot.Snapshot.Deserialize<RecipeState>(_jsonOptions);
        if (state?.Recipe is not null)
        {
            context.SetState(state);
        }
    };
}, LoggerFactory, initialState);

Recipe fields form one editor equivalence class: title, details, preferences, ingredients, and instructions all update immutable recipe records through RecipeChanged; section comparison only adds presentation highlighting for agent-originated changes. On the API side, one generate_recipe tool returns the complete recipe and AGUI.Server maps that result to a state snapshot:

// src/Components/AI/testassets/AGUIDojoApi/ChatClientAgentFactory.cs
var options = new AGUIStreamOptions();
options.MapResultAsStateSnapshot("generate_recipe");
return options;

The permanent browser replay replaces only AGUIDojoApi's model. Request assertions inspect the real API-side RunAgentInput for exact JSON state and one stable non-empty thread ID; DojoClient's keyed AGUIChatClient, both hosts, HTTP POST, SSE stream, tool invocation, and state mapping remain production code.

// src/Components/AI/testassets/DojoClient.E2E.Tests/Tests/SharedStateScenarioTests.cs
// First local edits must reach the model request and survive the first agent snapshot.
await editor.GetByLabel("Ingredient name").First.FillAsync("Zucchini");
await editor.GetByLabel("Ingredient amount").First.FillAsync("2, sliced");
await input.FillAsync(_firstPrompt);
await send.ClickAsync();
await _checkpoints.ReleaseAsync(_firstPrompt, "before-italian-recipe");
await Expect(editor.GetByLabel("Ingredient amount").Nth(1)).ToHaveValueAsync("2, sliced");

// The same field is edited again before turn two; the second snapshot must preserve the new value.
await editor.GetByLabel("Ingredient amount").Nth(1).FillAsync("3, sliced");
await input.FillAsync(_secondPrompt);
await send.ClickAsync();
await _checkpoints.ReleaseAsync(_secondPrompt, "before-herbed-recipe");
await Expect(editor.GetByLabel("Ingredient amount").Nth(1)).ToHaveValueAsync("3, sliced");
await Expect(editor.GetByLabel("Ingredient name").Last).ToHaveValueAsync("Fresh Basil");

Outcome

Equivalence class Result
Dependency-aware Dojo E2E build Passed with 0 warnings and 0 errors
Thread lifecycle, conversation identity, restoration, and typed state 14/14 focused runtime tests passed
Shared recipe state over the real dual-host AG-UI transport 1/1 filtered SharedStateScenarioTests passed

Review the product contract and UIAgent commit/restore boundaries first, then the DojoClient RunAgentInput serialization and API snapshot mapping; the recipe CSS and repeated editor-field markup can be skimmed. Acceptance criteria: manual recipe edits must be present in the next API request, a stable thread must span model calls and browser turns, agent snapshots must preserve unrelated/latest user edits, failed streams must not commit partial history, and restored state/history must behave like live state/history.

@javiercn
javiercn requested a review from a team as a code owner August 10, 2026 22:44
@javiercn
javiercn force-pushed the javiercn-components-ai-08-shared-state branch from 58a1ab0 to 8fae709 Compare August 11, 2026 07:20
@javiercn

Copy link
Copy Markdown
Member Author

/azp run aspnetcore-ci

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@javiercn

Copy link
Copy Markdown
Member Author

/azp run aspnetcore-ci

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI lite review requested due to automatic review settings August 12, 2026 13:20
@javiercn
javiercn force-pushed the javiercn-components-ai-08-shared-state branch from 8fae709 to c02241d Compare August 12, 2026 13:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a provider/protocol-neutral persistence contract to Components.AI so agents can commit streamed updates into a thread, then later restore conversation history, rendered blocks, and typed state from that thread. This PR also introduces the canonical “Shared State” dojo scenario (recipe editor) plus focused runtime and E2E coverage (including a deterministic recording) to validate stable thread identity and state forwarding across turns.

Changes:

  • Introduces IConversationThread and plumbs it through UIAgentOptions, UIAgent (commit + restore), and AgentContext (restore).
  • Adds the Shared State dojo scenario (UI + API tool mapping) and E2E replay asserting stable thread ID and exact serialized state in requests.
  • Adds runtime tests + helpers validating commit boundaries, failed-stream non-commit behavior, conversation identity forwarding, and restoration.

Reviewed changes

Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Components/AI/src/Engine/IConversationThread.cs New product contract for persisting committed streamed updates per conversation thread.
src/Components/AI/src/Pipeline/UIAgentOptions.cs Adds opt-in thread configuration to the agent options surface.
src/Components/AI/src/Engine/UIAgent.cs Persists streamed updates into a thread, forwards conversation IDs, and adds restore support.
src/Components/AI/src/Engine/AgentContext.cs Adds restore API that rebuilds Turns from restored blocks.
src/Components/AI/src/PublicAPI.Unshipped.txt Public API declarations for the new thread and restore methods.
src/Components/AI/test/TestHelpers/InMemoryConversationThread.cs Test helper thread implementation for commit/restore tests.
src/Components/AI/test/Engine/UIAgentThreadTests.cs Runtime tests covering thread commit, non-commit on failure, conversation ID forwarding, and restore behavior.
src/Components/AI/test/Engine/AgentContextThreadTests.cs Runtime tests covering restoring turns without firing callbacks and restoring empty threads.
src/Components/AI/testassets/DojoClient/DojoScenarios.cs Adds Shared State endpoint constant for the dojo client.
src/Components/AI/testassets/DojoClient/Program.cs Registers a keyed IChatClient for the Shared State scenario.
src/Components/AI/testassets/DojoClient/Components/Pages/Home.razor Adds Shared State scenario link to the dojo home page.
src/Components/AI/testassets/DojoClient/Components/_Imports.razor Imports Shared State scenario namespace for Razor components.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/SharedStateScenario.razor New dojo scenario wiring: agent, thread, state mapper, and editor/chat layout.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/SharedStateScenario.razor.css Styling for the Shared State scenario layout.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/SharedStateConversationThread.cs Dojo thread implementation that persists committed updates + captures conversation identity.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/RecipeSuggestions.razor Suggestion buttons that send prompts via AgentContext.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/RecipeSuggestions.razor.css Styling for recipe suggestion buttons.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/RecipeEditor.razor Typed recipe editor UI that edits immutable recipe state and triggers agent prompts.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/RecipeEditor.razor.css Styling for the recipe editor and “changed” section highlighting.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/IngredientRow.razor Ingredient row component for editing ingredient fields.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/IngredientRow.razor.css Styling for ingredient rows.
src/Components/AI/testassets/DojoClient/Components/Scenarios/SharedState/Recipe.cs Shared typed state models (RecipeState, Recipe, Ingredient) for the dojo client.
src/Components/AI/testassets/AGUIDojoApi/Program.cs Maps new /shared_state dojo API endpoint.
src/Components/AI/testassets/AGUIDojoApi/ChatClientAgentFactory.cs Adds Shared State system prompt, generate_recipe tool, and state snapshot mapping.
src/Components/AI/testassets/AGUIDojoApi/SharedState/RecipeResponse.cs Shared State tool response wrapper for API-side deserialization.
src/Components/AI/testassets/AGUIDojoApi/SharedState/Recipe.cs API-side recipe model for tool arguments/results.
src/Components/AI/testassets/AGUIDojoApi/SharedState/Ingredient.cs API-side ingredient model for tool arguments/results.
src/Components/AI/testassets/AGUIDojoApi/ScriptedChatClient.cs Adds canned shared-state tool-call behavior for the no-credentials scripted model.
src/Components/AI/testassets/DojoClient.E2E.Tests/Tests/SharedStateScenarioTests.cs Browser test validating state forwarding, stable thread ID, and preservation across snapshots.
src/Components/AI/testassets/DojoClient.E2E.Tests/ServiceOverrides/DojoModelOverrides.cs Adds Shared State recorded model override wiring.
src/Components/AI/testassets/DojoClient.E2E.Tests/ServiceOverrides/RecordedScript.cs Adds stable-thread assertion + expected state fields to the recorded script model.
src/Components/AI/testassets/DojoClient.E2E.Tests/ServiceOverrides/RecordedChatClient.cs Asserts request includes expected serialized state and stable thread ID.
src/Components/AI/testassets/DojoClient.E2E.Tests/Baselines/SharedState.recording.json Deterministic recording for Shared State scenario replay (state + thread requirements).

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Components/AI/src/Engine/AgentContext.cs
Comment thread src/Components/AI/src/Engine/UIAgent.cs
@kotlarmilos
kotlarmilos force-pushed the javiercn-components-ai-08-shared-state branch from c02241d to fea8616 Compare August 12, 2026 16:47
@javiercn
javiercn force-pushed the javiercn-components-ai-08-shared-state branch from fea8616 to c8cd78c Compare August 13, 2026 10:04
@ilonatommy
ilonatommy force-pushed the javiercn-components-ai-08-shared-state branch from c8cd78c to d6f392c Compare August 14, 2026 12:37
Comment thread src/Components/AI/src/Engine/UIAgent.cs
Comment thread src/Components/AI/src/Engine/AgentContext.cs
Comment thread src/Components/AI/src/Engine/UIAgent.cs
Comment thread src/Components/AI/src/Engine/UIAgent.cs
@ilonatommy
ilonatommy force-pushed the javiercn-components-ai-08-shared-state branch from d6f392c to 965f81b Compare August 20, 2026 06:55
Base automatically changed from javiercn-components-ai-07-agentic-generative-ui to main August 26, 2026 13:14
@ilonatommy
ilonatommy force-pushed the javiercn-components-ai-08-shared-state branch from 32ce38d to 2ae845f Compare August 26, 2026 13:46
javiercn and others added 5 commits August 26, 2026 15:49
Rationale: let provider-neutral Components.AI applications persist completed streaming turns, restore rendered conversation state, and carry service conversation identity without coupling the runtime to AG-UI.

Implementation: add the conversation thread contract, record and commit streamed updates in UIAgent, restore history and typed state through the existing mapping pipeline, and forward stateful conversation IDs on subsequent turns.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Rationale: demonstrate canonical Shared State across the real DojoClient to AGUIDojoApi HTTP/SSE boundary so user edits and agent updates operate on one recipe instead of independent copies.

Implementation: add the /shared_state endpoint and keyed AGUIChatClient, forward the stable thread ID and current recipe through RunAgentInput, map generate_recipe results to state snapshots, and render an editable recipe that preserves local fields across agent turns.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Rationale: verify completed streaming turns restore typed state while partial failures remain uncommitted, and prove local recipe edits cross the real AG-UI transport without replacing DojoClient's AGUIChatClient.

Implementation: add focused thread and restoration unit tests, recorded-model assertions for AG-UI state and stable thread identity, and a dual-host browser scenario that co-edits the recipe across two turns.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Rationale: keep the Shared State browser scenario deterministic while preserving the production DojoClient to AGUIDojoApi HTTP/SSE boundary and replacing only the API model.

Implementation: record two recipe turns with exact incoming state snapshots, stable thread identity, generate_recipe calls, tool continuations, and streamed assistant summaries.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: baddc46b-b86e-4be5-add2-1a7b3cac8195
@ilonatommy
ilonatommy force-pushed the javiercn-components-ai-08-shared-state branch from 2ae845f to 654c24b Compare August 26, 2026 13:52
Comment thread src/Components/AI/src/Engine/UIAgent.cs Outdated
Comment thread src/Components/AI/src/Engine/UIAgent.cs Outdated
@javiercn
javiercn merged commit 8c1a406 into main Aug 28, 2026
31 checks passed
@javiercn
javiercn deleted the javiercn-components-ai-08-shared-state branch August 28, 2026 21:02
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 12.0-preview1 milestone Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants