App Flow and Layer Boundaries

This page defines how the app’s flows (AppOrchestrator), its screens (Promethist.UI) and its domain managers (Promethist.Conversation, Promethist.Authentication, …) are allowed to reference each other, and why the rule is shaped the way it is.

All four stages have landed. The edges as they stand and Three problems in one costume describe the tree before the rework and are kept because they are why the design is shaped this way; everything from Target structure: two leaves and a contract module onwards describes the code as it is now. As built records the few places the implementation names something the design did not.

Two tops, one cycle

AppOrchestrator owns the flows the app moves through — opening a project, entering an interaction, handing over, leaving. That makes it the top of the dependency graph: it composes the conversation, visual, camera and authentication managers and sequences them.

The UI is the top of the runtime graph. It is what the user drives, and every flow has to be visible.

Whichever way those two are stacked, the one underneath has to reach up. That is the whole problem, and it is structural — not untidiness to be cleaned up in passing.

The symptom is that the reach-up gets faked. OrchestrationRequested is a nine-case event that exists only because Promethist.UI and Promethist.Conversation cannot call AppOrchestrator. And because Core.Events sits below the key types in App.State, that event cannot carry a ProjectKey or an AgentKey: every publisher stringifies (key.EngineURL.Address.ToString(), key.Ref) and the orchestrator rebuilds with EngineURL.FromUri(new Uri(e.BaseUrl)). The lossy round-trip is not sloppiness — it is what the layering forces.

The edges as they stood

Assembly references pointing UI-ward from below:

Edge Used for

Promethist.AppPromethist.UI

Five serialized screen references plus ShowScreen(AppScreen)

Promethist.AppUI.Popups

DialogController — load errors, foreign-region notice, one awaited confirm

Promethist.ConversationUI.Popups

DialogController (ConversationManager ×4), PopupController (MultimodalManager ×4)

Promethist.ConversationUI.Elements

SlideActionElement, inside the handover popup

Promethist.AuthenticationUI.Popups

PopupController — IdP picker and auth-waiting popups

Plus upward commands faked as events, because the reference cannot exist:

Faked edge Sites

UI → app

OrchestrationRequested, all nine cases

ConversationManager → app

OrchestrationRequested.ExitInteraction() ×2

MultimodalManager → app

OrchestrationRequested.HandoverInteraction(ref) ×2

MultimodalManager and AuthenticationManager do not merely reference UI — they build it, holding VisualTreeAsset fields and querying instantiated trees (root.Q<VisualElement>("choice-options")). Both are UI code with a domain attached.

Three problems in one costume

The edges above look like one problem and are three. Conflating them is what makes any single fix feel forced.

P1 — Presentation control. A flow needs a screen shown or hidden. AppOrchestrator → screens.

P2 — User answers. A flow must stop, ask the user something, and continue with the answer. Everyone → DialogController / PopupController.

P3 — Flow triggers. A component learns something and a flow has to run. ConversationManager, MultimodalManager, UI → AppOrchestrator.

P2 is the load-bearing one. Four of the five real assembly edges are P2. As long as "ask the user" is spelled UI.Popups.DialogController, every layer that needs to ask anything must reference UI — so fixing P1 alone moves the cycle rather than removing it.

Target structure: two leaves and a contract module

Stop stacking the flows and the screens on each other. Make both leaf assemblies that nothing references, meeting on a shared contract module:

      Promethist.Application                Promethist.UI
      (all flows - leaf)                    (all screens - leaf)
               |                                   |
               +--------> Promethist.Contracts <---+
                            ^      ^      ^
      Promethist.Conversation      |      Promethist.Authentication,
                                   |      Promethist.Visual, ...
                            Promethist.Core
                        (EventBus, ServiceLocator, Log)

Nothing references Promethist.Application, and nothing references Promethist.UI. There is no pair of assemblies with an edge in both directions and no path that could grow one, so cycles become structurally impossible rather than a thing to be careful about. Either leaf could be deleted or replaced wholesale — which is the same property that makes the app runnable headless (see Headless operation).

App.State already was the contract module in everything but name: it holds ProjectKey, AgentKey and AppState, depends only on Core, EngineCommunication and SystemNative, and was already referenced by App, UI, Conversation, Authentication and Telemetry — exactly the right set. It is now Promethist.Contracts (it was also the one assembly missing the Promethist. prefix), living in Assets/Scripts/Contracts/, with three jobs:

  • shared state and keys — what it holds now

  • domain events with typed payloads, moved off Core.Events, which is what retires the baseUrl/agentRef round-trip

  • ports — the interfaces the leaves meet through: IAppFlows and IPromptService

The existing App.State namespace stays as-is so no using changes; new events and ports live in App.Events and App.Ports. The awaitable ports do need one asmdef change: an explicit assembly definition never inherits autoReferenced packages, so Promethist.Contracts lists UniTask like every other assembly does.

The two leaves still have to be introduced to each other at runtime, and ServiceLocator already is that composition root: UI registers its IPromptService, the orchestrator registers its IAppFlows. No assembly edge either way.

Promethist.UI keeps referencing Promethist.Conversation directly, for chat entries and mute state. That is not a cycle — it is a leaf reading a module beneath it — and putting an interface in front of it would buy nothing. The contract module is for the flow boundary, not for every read.

The three channels

Every cross-layer interaction goes through exactly one of three channels, each one-directional.

Channel Direction Blocking Mechanism

State

any → everyone

No

EventBus, typed domain events. Zero-or-many listeners; the publisher never learns who. Solves P1.

Prompts

any → UI

Yes (awaitable)

IPromptService port, implemented by UI. Solves P2.

Commands

UI → app → managers

Optional

Direct method calls through a port interface. Solves P3 from above; P3 from below becomes State.

Events publish facts, never instructions

A component publishes what happened to itself. It never publishes what someone else should do.

CurrentProjectChanged, InteractionStateChanged, ConversationEnded, EngineHandoverRequested are facts about the publisher’s own domain. OrchestrationRequested.Navigate(AppScreen.Home) is an instruction for a specific listener, and ProjectUIOpened would name a listener’s mechanism in the publisher’s vocabulary. Neither is allowed. If an event name contains a UI noun, or reads as an imperative, it belongs on another channel.

That rule is what lets P3-from-below dissolve: ConversationManager does not tell the app to tear down, it reports that its conversation ended and lets the orchestrator decide what that means.

Commands are calls, not events

A command has exactly one legitimate handler, often a return value, and needs to show up in a stack trace. An event has zero-or-many handlers and returns nothing. OrchestrationRequested was a command wearing an event’s clothes, which is precisely what made it feel dirty — routing a single-handler call through a broadcast bus loses the handler, the result and the trace all at once.

State: the interaction lifecycle

The orchestrator publishes where it is in an interaction. It does not publish, or know, what that looks like.

public enum InteractionState
{
    None,           // home space; nothing running
    LoadingVisual,  // heavy asset load - determinate, LoadUpdated feeds progress
    LoadingSession, // visual up, engine session opening - indeterminate
    Active,         // conversation running
    Ending          // session closing and visual unloading
}

// No payload - the state is readable at its source. See "Read state, don't mirror it".
public readonly struct InteractionStateChanged : IEvent
{ }

A single controller in Promethist.UIInteractionSpaceController — projects that onto screens. This is ShowScreen moved to where it belongs, and it is the only thing in the app that knows the mapping:

State Loading overlay Conversation space Home space

None

hidden

hidden

visible

LoadingVisual

progress, opaque

LoadingSession

spinner, transparent

visible, chrome hidden

hidden

Active

hidden

visible, chrome shown

hidden

Ending

spinner

hidden

hidden

Entering cold is None → LoadingVisual → LoadingSession → Active. Because LoadingVisual is an opaque overlay, which space sits behind it is undefined, so the projection only switches spaces at LoadingSession and None.

Handover is the shorter Active → Ending → Active, because it is the one flow whose loading the user watches from a screen of its own: the evaluations of the conversation that just ended, then AgentLoadingPrompt, which shows the incoming agent with the load running under it and offers its Continue button once that lands. Both take the loading overlay down while they are up, so the state is left at Ending until the user is through with them — a transition in between would put the overlay back over the screen the user is reading.

What precedes it depends on how many agents the engine offered. HandoverSpec.agentRefs holding one makes the popup a confirmation, accepted by a countdown running out; holding several makes it a pick between agent cards, where nothing is taken by inaction. A pick is also an answer — the chosen agent’s ref goes back to the engine as a multimodal response, the way a choice or an input answers one — so it is sent from AppOrchestrator rather than MultimodalManager, which is what keeps the response and the session end that follows it from being separated by a refusal. Because it is an answer, the agent gets to reply to it: the handover holds until the agent has spoken again, so a parting line is heard out rather than cut off by the session ending. It waits with ConversationManager.WaitForSpeechEndAsync(ignoreCurrentSpeech: true) — the same wait SuggestEndAfterTurn uses, with the extra leg that the speech still in flight when the pick was made is not the reply, so it is waited out first and NextSpeechGraceSeconds bounds how long a reply that never comes is waited for.

A screen declares its own space and whether it is an overlay, in code:

protected internal override UISpace Space => UISpace.Home;
protected internal override bool IsSpaceOverlay => true;   // a sidebar, a detail panel

UIController registers itself into UIScreens on Awake when it names a space, so the projection needs no serialized references at all — the one fact about a screen lives with that screen, and nothing about the mapping is wired in the scene. Entering a space shows only its non-overlay screens; leaving one hides everything in it, overlays included. Chrome is the same idea one level down: a screen that can be up with its controls held back implements IChromeScreen, which is how LoadingSession shows the agent’s visual with nothing to talk to yet.

Read state, don’t mirror it

InteractionStateChanged carries no payload, for the same reason CurrentProjectChanged does not: the state is readable at its source, so the event only has to say go look again. A subscriber’s handler and its OnUIReload then run the same code path — HomeController.Refresh() is the existing example, reading AppState.CurrentProjectKey fresh in both cases — instead of one path handling a transition and another re-applying a cached copy that can disagree with it.

That is a rule about ownership, not about reloads:

  • App state — the orchestrator owns it, and it exists with no UI in the scene. UI reads it: AppState.CurrentProjectKey, IAppFlows.InteractionState, IAppFlows.CurrentAgent.

  • UI state — nothing below UI knows it exists, so UI owns it, caches it in the controller and re-applies it on reload: AgentDetailController._currentAgentKey (which agent’s detail is open), UIController._visible.

Surviving a reload is UI’s job in both cases, and UIController already does it — a controller that caches its own presentation state across reloads is working correctly, not working around a missing getter. What reading the source buys is that a projection of app state cannot silently drift, for two reasons specific to this codebase:

  • EventBus.InvokeSubscribers skips subscribers whose GameObject is disabled and never replays what they missed, so a mirrored copy is wrong from that point until the next transition. A reader is never wrong.

  • Mirror-only correctness needs every subscriber to be listening before the orchestrator’s first transition. Today that holds only because of the await UniTask.WaitForEndOfFrame() in AppOrchestrator.Start, which is there for an unrelated authentication reason — so UI correctness would silently depend on an auth workaround staying put.

A payload-less event cannot express a transition, only the new state — two changes in one frame coalesce into one notification of wherever the state landed. That is what a projection wants. If UI later needs to tell a cold enter from a handover (to play a different animation into LoadingVisual, say), that is the point to add a payload, and not before.

Commands: IAppFlows

namespace App.Ports
{
    public interface IAppFlows : IService
    {
        InteractionState InteractionState { get; }
        AgentKey? CurrentAgent { get; }

        void OpenProject(ProjectKey key);
        void OpenLink(Uri url);
        void RemoveProject(ProjectKey key);
        void LogOutOfProject(ProjectKey key);
        void RefreshCurrentProject();

        void EnterInteraction(AgentKey key, string initialAction = null);
        void HandoverInteraction(AgentKey key);
        void ExitInteraction();

        void ReportIssue();
    }
}

Commands are void because every call site is fire-and-forget; the async UniTask<bool> OpenProjectAsync stays private to the orchestrator, where OpenAgentAsync awaits it. ServiceLocator.Register<T> keys on typeof(T), so ServiceLocator.Register<IAppFlows>(this) registers under the interface with no locator change.

OpenLink is the deep-link router offered as a command: the projects sidebar’s "add" takes a pasted link as well as a bare project ref, and where a link leads - a project, an agent, an unsupported relay - is the orchestrator’s decision, made in one place for a pasted link and a followed one alike. A bare ref stays the sidebar’s own business, since it names a project on the default engine and never a link.

RefreshCurrentProject is the ex-passenger of ShowScreen(AppScreen.Home), which invalidated the project cache and re-fetched — a data concern that had been riding inside a UI switchboard. Exposed as a command it also answers pull-to-refresh.

LogOutOfProject is RemoveProject without the removal: it drops the stored refresh token and the identity fields on SavedProject, and the project stays in the sidebar as a signed-out one. The cached ProjectInfo goes with the token — it was fetched with the identity that just went away, and a different account (or none) can see a different agent list — so the project is invalidated either way, and reopened at once when it is the one on screen. It is offered only for a project whose SavedProject.IsIdentityRequired is false; the UI does not present the action at all for the others, since one that requires an identity would only prompt for a new login on the spot.

Terms and conditions gate

A project whose ProjectInfo.terms is non-empty has to have those terms accepted before it opens. OpenProjectAsync asks with a TermsPrompt after the login step — the terms can differ per identity, so they are read from the info fetched with the identity — and before anything is committed: a decline returns false and publishes ProjectLoadFinished(key, false), leaving CurrentProjectKey and SavedProjects untouched, so the previously open project stays open and a declined project never joins the sidebar list.

Acceptance is persisted, because it must not be asked for twice. SavedProject.AcceptedTerms holds an FNV-1a fingerprint of the accepted text rather than the text itself: the marker stays small, and rewritten terms produce a different fingerprint and are therefore asked about again. RefreshCurrentProject is the reason this cannot be a per-session flag — it re-opens the current project after every interaction, which without a persisted marker would put the terms back on screen at the end of every conversation.

Every open of a project that has terms also dumps them verbatim to {persistentDataPath}/Terms/{ref}.md and logs the path. That is a debug aid for reading the Markdown exactly as the engine sent it — the file is overwritten each time and nothing reads it back.

The prompt’s DefaultResult is false, so a headless run declines rather than silently accepting on the user’s behalf. That means a headless run cannot open a project with terms at all; that is the correct trade for a consent gate.

Issue reports

ReportIssue is the flow behind the two entry points the user has for telling us something is wrong: the Support row at the end of the settings screen, and a hold on the settings button for anyone not in dev mode — the hold opens the debug panel instead once dev mode is on, which is where a developer’s own report would go anyway.

The prompt collects only what the user knows: the kind of problem (IssueType) and, optionally, what happened. Each type is a sentence rather than a name, which is why the popup lists them as rows of its own instead of reaching for OptionPicker — that control keeps its options on one line, and its list is built on the panel root where the popup’s own stylesheet cannot reach it. Nothing is picked to begin with and Submit stays disabled until something is, so a report never carries a type the user never chose. Everything else that makes a report usable is filled in by the orchestrator and never asked for — Application.version, DeviceRef.Get(), and the session the report is about. That session is ConversationManager.CurrentSessionId while a conversation is running and the last one there was afterwards, since a user typically reports a conversation once it has already gone wrong and ended. The orchestrator remembers it from SessionInfoChanged, which is why the flow needs no session of its own.

EngineAPI.ReportIssueAsync POSTs it to api/report/issue on the open project’s engine, with the project’s identity as a bearer token when there is one — an anonymous user can still report. IssueReport.Name is the tracker entry’s title, derived rather than asked for: the head of the description, or the type’s name when there is no description. sessionId is the one field that can be absent — it is dropped from the payload rather than sent empty when there is no session to name. IssueType serializes by name rather than ordinal, so the values have to keep matching the engine’s own enum.

Both outcomes are then reported back with a MessagePromptapp.info.issueReportSent on a filed report, app.error.issueReportFailed on a POST that did not land. A report is a one-way handover the user gets nothing else back from, so the popup closing on its own would leave them unable to tell a filed report from a lost one.

Prompts: asking the user from any layer

The port that removes four of the five assembly edges. It separates asking from rendering the question: the API is declared in the contract module, the UI Toolkit implementation stays in Promethist.UI.

namespace App.Ports
{
    public interface IPrompt { }

    public interface IPrompt<out TResult> : IPrompt
    {
        TResult DefaultResult { get; }
    }

    public interface IPromptService : IService
    {
        UniTask<TResult> Show<TResult>(IPrompt<TResult> prompt);

        /// <summary>Shown for the duration of some work rather than until answered.</summary>
        IDisposable ShowBusy(IPrompt busyPrompt);
    }
}

Callers never resolve the service themselves — they go through the static Prompts, which falls back to answering with DefaultResult when nothing is registered. That is what makes headless a property of the port rather than of every call site:

var answer = await Prompts.Show(MessagePrompt.Confirm("End the conversation?"));
using (Prompts.ShowBusy(new AuthWaitingPrompt(...)))
    ...

Prompt types are plain data in the contract module. UI owns a PromptPresenter : IPromptService that maps prompt type to VisualTreeAsset and builder, living beside PopupController — which is where all the Q<VisualElement>("choice-options") tree-building from the managers ended up.

Prompt Answer Replaces Drops edge

MessagePrompt

MessageAnswer

DialogController.Show — nine sites across App and Conversation

App, Conversation, AuthenticationUI.Popups

ChoicePrompt, TextInputPrompt

chosen text, null when skipped

MultimodalManager popups and their tree-building

ConversationUI.Popups

ImagePrompt

closed

MultimodalManager image popup

ConversationUI.Popups

HandoverPrompt

chosen agent, null when refused

The handover popup and its slide gesture

ConversationUI.Elements

TermsPrompt

accepted

Nothing — added later, for the terms gate. A MarkdownLabel filling the popup card.

IdpPrompt

IdentityProviderInfo, null when backed out

AuthenticationManager provider picker

AuthenticationUI.Popups

AccountPrompt

AccountAction

— new with the project sidebar’s account row

AuthWaitingPrompt (busy)

— (reports the user’s way out through Answered)

AuthenticationManager waiting popup

AuthenticationUI.Popups

EvaluationsPrompt

finished by the user

evaluationsUI.Show(agentKey, sessionId)

AppPromethist.UI

AgentLoadingPrompt

continued by the user

— new with the reworked handover

AppPromethist.UI

ReconnectingPrompt (busy)

— (reports the user giving up through Abandoned)

— new with session reconnect

IssueReportPrompt

IssueReportAnswer, null when cancelled

— new with issue reports

AgentLoadingPrompt is the one prompt that carries a task: the load it waits out, which is what turns its indicator into the Continue button. The orchestrator awaits that same task, so it hands over a Preserve()`d one — a plain `UniTask can only be awaited once.

The task is a UniTask<bool>, joining the visual load with the false that a session which never opened answers with — see the A session that never opens trap below. A screen that cannot go on anywhere must not offer to: the loading screen takes itself down on a false exactly as it does on a throw, and answers false itself, leaving the orchestrator’s own wait to read the same answer and retreat.

Preserve() alone is not enough, and the gap is not obvious. It memoizes the result, not the registration: while the task is still pending, MemoizeSource.OnCompleted forwards straight to the inner source, so a second awaiter arriving before the first has finished throws Already continuation registered. A preserved task takes a repeat await after it completes, never a concurrent one. So the handover waits for the screens and only then for the load — by which point the screen has waited it out and the second wait reads the memoized result. That second wait is where the orchestrator reads whether the session opened, and it also covers the case where no presenter showed anything at all, which leaves the orchestrator the only awaiter.

Like the evaluations it follows, it is presented as a whole screen rather than a popup — AgentLoadingUI, in the agent detail screen’s language (hero thumbnail, name, title, description card). It covers the whole device rather than the capped column the other screens keep to, because what is behind it is the 3D visual being swapped, which must not show through while it changes. That is the IsWidthCapped ⇒ false case the settings screen also takes: the root keeps the device for the backdrop and the panel inside it is capped instead.

ShowBusy is a distinct shape because auth-waiting is not a question — it is "keep this on screen while I work". Modelling it as a prompt with an answer nobody reads would be a lie about the flow. It still offers the user two ways out, which it hands back through a plain Answered callback rather than a result, because the login landing on its own is the outcome the caller is actually waiting for.

ReconnectingPrompt is the same shape for the same reason, and shows what the shape is worth beyond auth: the orchestrator puts it up on ConversationInterrupted and disposes it on ConversationResumed, on ConversationLost, and on any SetState — a reconnect only runs inside a live interaction, so a transition means the notice is stale even in the flows that cancel the reconnect without it reporting anything back. Its one button reports through Abandoned, and the orchestrator answers that with the ordinary ExitInteractionAsync: leaving cancels the reconnect on its way out, so the user gets no "conversation ended" report for a call they made themselves.

DialogController keeps its own DialogType/DialogButtons/DialogResult: it is a self-contained widget, and PromptPresenter is exactly the place that maps MessagePrompt onto it. MessageButtons.None maps to DialogButtons.None, which hides the whole button row and drops the backdrop out of OnBackdropClicked’s dismissal — a dialog that never settles, so it holds the queue behind it and its awaiter never resumes. Only `MessagePrompt.Blocking asks for it, and only for a state the app cannot carry on from. Headless is the one exception: with no IPromptService registered the prompt answers DefaultResult immediately, since there is nothing to be stuck in front of.

One handle per popup, torn down before the answer lands

PopupController serialises popups through a queue, so a request is not always on screen at the moment it is made. Three invariants keep that out of the callers.

Closing goes through the handle ShowWithContent hands back, never through "close the current popup". A PopupHandle takes down its own popup wherever it is: hiding it when it is the one on screen, dropping it from the queue when it never got there, doing nothing when it is already gone. A ShowBusy handle disposed before its popup was ever shown therefore cancels the request instead of closing whatever else happened to be up.

That case is not hypothetical — it is how login on Android used to hang. Opening the auth webview backgrounds the app and freezes the UI Toolkit scheduler mid-transition, so the auth-waiting popup was still sitting in the queue behind the closing provider picker when the login came back. Disposal found nothing current and did nothing; the transition then thawed on resume and put the popup on screen with no handle left to take it down. The prompt is deliberately not dismissable, so the app was stuck.

The way out a popup offers the user is the shell’s, not the content’s. ShowWithContent’s `dismissable is the single switch for it: it both arms the backdrop and shows the corner close that PopupUI.uxml carries inside the card. A content tree therefore holds only the buttons that mean something — an option, a submit, an answer — and never its own X, so every popup’s way out sits in the same place and is spelled the same way at every call site. A popup with an answer to capture is dismissable when that answer has a default, and ChoicePrompt/TextInputPrompt both are: closing resolves the null the presenter started with. They used to take the engine’s isSkippable for it, which the multimodal model no longer carries — a question the user cannot close is a dead end when the answer is optional to the engine anyway, so they are simply always dismissable.

A prompt’s await resolves when its popup is fully torn down, not when the user clicks. The presenters capture the answer on click, close the popup, and resolve from onDismissed. UniTaskCompletionSource continues inline, so resolving on the click instead runs the whole remainder of the caller’s flow — including any popup it opens next — inside the click handler, while the popup that triggered it is still the current one. Anything it opens is queued behind a popup that is only about to start closing. The cost is that a prompt now takes the transition duration to answer.

Evaluations is just a prompt

Evaluations looked like a special case — a whole screen the app has to wait on, not a modal — but the shape is identical to any other prompt: present this, tell me when the user is finished. Nothing new is needed for it.

EvaluationsUI already has both completion signals the port wants: EvaluationsController.NothingToShow for "the engine had nothing to review" and the done button for the user finishing. It resolves the prompt instead of publishing Navigate(AppScreen.Home), and stops deciding where the app goes next.

One wrinkle the shape does not cover: the review is shown while the state is still Ending, so the teardown spinner is up, and the loading panel sorts above every screen. The presenter therefore takes the overlay down for the review’s duration; the projection puts it back on the next transition, which is the None that follows.

Worked example: leaving a conversation

The whole exit flow, in one readable method in AppOrchestrator, with the evaluations wait as an ordinary await:

private async UniTask ExitInteractionAsync()
{
    var agent = _conversationManager.CurrentAgentKey;
    var session = _conversationManager.CurrentSessionId;

    SetState(InteractionState.Ending, agent);
    await UniTask.WhenAll(_conversationManager.EndConversationAsync(), _visualManager.SelectNoneAsync());

    if (session.HasValue && agent.HasValue
        && AppState.TryGetAgentInfo(agent.Value, out var agentInfo) && agentInfo.HasEvaluations)
        await Prompts.Show(new EvaluationsPrompt(agent.Value, session.Value));

    SetState(InteractionState.None, null);
    RefreshCurrentProject();
}

Compare with what it replaces: the same flow spread across ExitInteraction, ShowScreen, EvaluationsUI.OnDone and an OrchestrationRequested.Navigate round-trip, with the "does this agent have evaluations" decision made in the app layer and the "so show that screen" decision made two hops away.

Headless operation

With no IPromptService registered, Show returns the prompt’s DefaultResult immediately. The orchestrator therefore runs every flow end to end with no UI in the scene at all — by construction, not by discipline, and the same holds for a fake provider in a test.

That is the acceptance criterion for this design. If a flow cannot run without UI present, the boundary has been crossed somewhere.

Traps

Only publish the ends nobody asked for. A general "the conversation ended" event re-enters the orchestrator: its exit flow calls EndConversationAsync(), which reaches OnDisconnected, which publishes, which starts the exit flow again. A state guard on Ending looks like the fix and is not enough — handover also ends a conversation legitimately, and now runs at Ending for its whole duration, so an Ending-only guard is blind to which end it is looking at: it would swallow a genuine drop of the incoming session as readily as it hides the spurious exit flow.

The orchestrator is the only thing that ends a conversation on purpose, so the only end that needs a channel at all is the one it did not cause. ConversationLost is published only for an unintentional end — ConversationManager already carries the distinction it needs in wasIntentional, which it already uses to decide whether to report the drop to the user. Re-entrancy is then impossible rather than guarded against, and this is why the event needs no reason payload: the other two ends a reason enum would encode do not belong on it. Requested needs no event, because the orchestrator is the caller; an agent proposing an end is ConversationEndSuggested, a different fact at a different moment — see below.

OnDisconnected early-returns while _session is null, so a repeated disconnect cannot publish twice. Keeping a cheap if (InteractionState is Ending or None) return; at the top of the handler is still worth it as insurance, but it is not what makes this correct.

The same distinction now carries a second decision: an unintentional drop is resumed on the session id it lost before it is given up on, and ConversationLost is published only once that fails. ConversationInterrupted and ConversationResumed report the attempt, and an end the app asked for is never resumed — see Session Reconnect.

A session that never opens. CreateConversationAsync answers false rather than throwing, because the flows that call it are Forget()`ed — an engine error arriving before the session is ready (a refused tenant, a socket that drops) surfaced only as an unobserved exception, and the app stayed on its loading state with the overlay up and no way out. `ConversationManager.ReportStartFailedAsync owns the report: conversation.error.startFailedReason when the engine named a reason meant for the user, conversation.error.startFailed when it did not, and the untranslated internal cause appended in dev mode only — the rule the engine’s Description already follows. It is public because the orchestrator’s own preconditions (an unknown agent, an agent outside the open project) fail the same interaction before a conversation is ever asked for. The orchestrator owns the retreat, AbortInteractionAsync, which unloads the visual and returns to None. ConversationLost is not involved and there is nothing to end — the conversation never connected.

A startup that opens nothing. Every failure inside OpenProjectAsync — a project the engine will not serve, declined terms, a cancelled login — answers false and leaves CurrentProjectKey pointing at a project no ProjectInfo was ever cached for. Nothing else recovers from that: CurrentProjectChanged is only published for a load that succeeded, so HomeController.Refresh logs its missing project and returns, and the app sits on placeholder Home and Projects screens with no route out — the saved project is reopened on every launch, so it is not even a restart away. OpenStartupAsync owns the recovery: it awaits the one open the app starts on and falls back to the default project when it answers false. Only startup falls back — a project the user picks, or a deep link followed while the app is running, fails against an app that already has somewhere to be.

When the default fails too — either as that fallback or as the project the app started on — there is nothing left to try, and the app ends on MessagePrompt.Blocking: a dialog with no buttons, ignoring its backdrop, telling the user to restart the app or reload the page. Awaiting it never resumes, which is the point. It is deliberately a dead end rather than a retry button, because everything a retry would re-attempt has already been attempted, and the states behind the dialog are the empty screens this trap is about.

This is why OpenLink became OpenLinkAsync and answers whether a project ended up open rather than whether the link parsed. Startup needs the outcome, not the recognition, and the two came apart in both directions: a relay link was "handled" and opened nothing, an unrecognized one was not handled and fell through to the current project. OpenAgentAsync returns the same fact so an agent link’s project counts, however the interaction that follows goes.

The default project is not always on production. AppState.DefaultEngineURL reads the page’s own host on Web, so the preview and PRP deployments default to the engine that served the client rather than the production one their region resolves to — the client and its engine are the same host, and a build shipped from one that talks to another has no project of its own to fall back to. Anything the host does not parse into a Promethist engine (a local build, a bare host) stays on production, and every other platform keeps resolving the region as before.

Flows left behind in managers. ConversationManager.EndAfterTurn asked the user "end the conversation?" and then commanded the exit — a flow living in a manager. It is now SuggestEndAfterTurn, which mutes, waits out the agent’s speech and publishes ConversationEndSuggested; the orchestrator owns both the prompt and the teardown. The "no" branch needs a way back, so the manager grew one command for it — SendDecline(), which restores the mute state and sends the engine’s decline. Likewise the handover popup: MultimodalManager reports EngineHandoverRequested and the orchestrator decides whether to confirm with a HandoverPrompt first. Both are more churn than the port strictly requires, and both are the same rule applied consistently.

Two questions about the same conversation. The engine can ask both at once: a sessionShouldEnd command and a handover interaction in the same turn. SuggestEndAfterTurn waits out the agent’s speech before publishing, so the suggestion typically lands while the handover popup is already up and counting down — and a confirmation dialog then stacks on top of a question the user is mid-answer to. The offer subsumes it: accepting the handover ends this conversation anyway, refusing it is the same "keep talking" the dialog’s "no" means. So a suggestion arriving while the offer is up is ignored outright, ResolveEngineHandoverAsync clearing the flag in a finally so a popup that fails to present cannot silence every later suggestion too. The flag stays set through the wait for the agent’s reply as well: that reply is exactly what SuggestEndAfterTurn waits out before publishing, so lifting it earlier would put the dialog on screen in the seconds between the pick and the handover taking over. What a refusal owes the engine is the same #decline the dialog’s "no" sends, only carrying the offer’s ref — so it names the interaction turned down rather than overloading the bare notice, and it lifts the mute SuggestEndAfterTurn forced along the way.

The window after the offer is a different matter, and needs no flag — a handover runs at Ending for its whole duration, so the state guard at the top of OnConversationEndSuggested covers it. That guard is also why the one inside ResolveSuggestedEndAsync stays: it catches the state changing while the dialog is up, which the entry check cannot see.

AppScreen in the foundation. The enum lived in Core.Events, below everything, naming UI screens. It is deleted, not moved.

Migration

Each stage compiles and ships on its own, and deletes edges on its own. All four have landed.

Stage Change Drops

0

App.StatePromethist.Contracts. Delete AppScreen from Core.Events.

1 (P3)

Typed domain events in the contract module: ConversationLost, ConversationEndSuggested, EngineHandoverRequested, InteractionStateChanged, ProjectLoadFinished, AgentOpened. Delete OrchestrationRequested.

Conversation and Multimodal stop knowing the app layer exists; the baseUrl/agentRef round-trip goes

2 (P1)

IAppFlows; UI projects InteractionStateChanged onto screens; delete the five serialized screen references and ShowScreen.

AppPromethist.UI

3 (P2)

IPromptService with MessagePrompt first, then the popup prompts, then EvaluationsPrompt.

App, Conversation, AuthenticationUI.Popups; ConversationUI.Elements

ProjectLoadFinished in stage 1 also retires the two // TODO Inform of end of load for pull to refresh comments in OpenProjectAsync, which existed because the orchestrator had no way to report a finished load that did not involve touching UI. It carries the project and whether the load succeeded, because a failed load is precisely the end that CurrentProjectChanged cannot report.

As built

Four things the design did not name, and one thing it got wrong.

AgentOpened. Opening an agent link without an action used to end in agentDetailUI.ShowAgent(key). Stage 2 has nowhere to put that call, and it is not a flow decision — so OpenAgentAsync publishes the fact that an agent was opened and AgentDetailController decides that an opened agent looks like its detail card.

The space registry. See Target structure: two leaves and a contract module above — UISpace, UIScreens and IChromeScreen. The alternative was six serialized references on the projection, half of them into prefab instances.

Prompts. The static entry point in front of IPromptService, so the headless fallback lives in one place instead of at every call site.

SendDecline. The command the "keep talking" branch of ConversationEndSuggested needs, now that the manager no longer owns that question.

The UniTask reference. The design claimed the awaitable ports needed no asmdef change. They do — see Target structure: two leaves and a contract module.

Two pieces of pre-existing debt are also worth knowing about, both in Prefabs/UI/Screens/Conversation UI.prefab: it carries components for UI.ConversationUI and UI.UIDocumentDataSourceBinder, neither of which exists any more. The scene’s instance adds the real ConversationController on top, so nothing is broken, but the prefab shows two missing scripts until someone removes them.