Systems

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.App → Promethist.UI

Five serialized screen references plus ShowScreen(AppScreen)

Promethist.App → UI.Popups

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

Promethist.Conversation → UI.Popups

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

Promethist.Conversation → UI.Elements

SlideActionElement, inside the handover popup

Promethist.Authentication → UI.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.UI — InteractionSpaceController — 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);
        UniTask<OpenOutcome> OpenLinkAsync(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(Guid? turnId = null);
    }
}

Commands are void because nearly every call site is fire-and-forget; the async OpenProjectAsync stays private to the orchestrator, where OpenAgentAsync awaits it. The one exception is OpenLinkAsync, for a caller that offers to try again: it answers an OpenOutcome - Opened, NotFound, AgentNotFound (the agent’s project opened, but it serves no such agent), Declined (terms or login) or Unrecognized. A not-found error dialog is awaited before the outcome is answered, so the caller’s retry never lands under it; startup reads Opened and AgentNotFound alike as a project being open. 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: 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. The projects sidebar’s add popup turns whatever the user entered into a link before handing it over, so it goes through the same router - see "Adding a project or agent" in ui/ui.adoc.

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 three entry points the user has for telling us something is wrong: the Support row at the end of the settings screen, 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 — and Report an issue on the copy menu a held chat message opens. The last is the only one that names a turn; see the copy menu for how the message hands it over.

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. A report opened from one chat message also carries that message’s turn, which is the only part of a report that comes from its caller rather than from the orchestrator’s own view of the app. A report that falls back to the last session gets a note appended to its description, so the tracker can tell it was filed after the session rather than during it. 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 first 50 characters of the description, or "No name" when the description is 10 characters or shorter. sessionId and turnId are the fields that can be absent — each is dropped from the payload rather than sent empty when there is nothing to name, and turnId is absent for every report not opened from a message. IssueType serializes by name rather than ordinal, so the values have to keep matching the engine’s own enum.

A failed POST is retried three times, after 2, 5 and 15 s, before the report counts as lost; only no-response, 408, 429 and 5xx failures retry, since the engine rejecting the payload would reject it again (the retry lives in EngineAPI.PostJsonWithRetryAsync, shared with feedback submission) — the report is the most valuable thing a user sends us, and a flaky connection is the likeliest reason it fails. The user waits through the retries without a popup, up to about a minute and a half with every attempt timing out. Filing is logged before the first attempt, naming the project key, so a report the engine never received still leaves a trace of which engine it was sent to.

Both outcomes are then reported back with a MessagePrompt — app.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.

Push notification navigation

A notification’s data is a flat string map naming a projectRef and, optionally, one navigation entry, resolved in this order: an inline entry - its type with that type’s fields (actionName or text, each with an optional agentRef, route, url), any ref beside it ignored; a ref to an entry of the project, folders included; otherwise the project alone is opened. A notification without projectRef is ignored. Only a tap acts on it — PushNotificationHandler publishes PushNotificationOpened when NotificationOpened is set, so one arriving in foreground never navigates away. The orchestrator opens the notification’s project and publishes NavigationOpened. A tap during an interaction first asks whether to leave it; a yes goes through the ordinary exit, survey and evaluations included, before the project switches, and a no drops the notification.

A tap that launched the app is held until OpenStartupAsync is done, so its open does not race the startup one, and the app goes through the last project first. Opening the notification’s project in its place was rejected: Firebase hands the tap over only once listening and never says when none is queued, so startup would have to wait out a grace period on every cold start. The handler posts the event to the main thread, as Firebase invokes MessageReceived straight from a native callback with no guarantee of the main thread. HomeController activates it through NavigationCardBuilder.Activate, exactly as a tapped card, folders included.

On Android, SplashActivity extends Firebase’s generated MessagingUnityPlayerActivity. A tap on a running app arrives through onNewIntent, which only that activity forwards to Firebase; a cold start is read from the launch intent either way.

The projectRef alone names no engine. The push token is registered per engine for saved projects only, so the project is looked up among them, the open one first; an unsaved project, or a ref-only entry the project does not list, is logged and dropped.

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, Authentication → UI.Popups

ChoicePrompt, TextInputPrompt

chosen text, null when skipped

MultimodalManager popups and their tree-building

Conversation → UI.Popups

ImagePrompt

closed

MultimodalManager image popup

Conversation → UI.Popups

HandoverPrompt

chosen agent, null when refused

The handover popup and its slide gesture

Conversation → UI.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

Authentication → UI.Popups

AccountPrompt

AccountAction

— new with the project sidebar’s account row

—

AuthWaitingPrompt (busy)

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

AuthenticationManager waiting popup

Authentication → UI.Popups

FeedbackPrompt

finished by the user

— new with feedback surveys

App → Promethist.UI

EvaluationsPrompt

finished by the user

evaluationsUI.Show(agentKey, sessionId)

App → Promethist.UI

AgentLoadingPrompt

continued by the user

— new with the reworked handover

App → Promethist.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.

Feedback surveys

A feedback survey is the same shape again — present these definitions, tell me when the user is through — and it is asked before the evaluations of the same conversation, so the user answers about the conversation before being handed its review.

The engine decides whether there is anything to ask at all. GET api/feedback/specification answers with every definition eligible right now, and an empty array is the ordinary case. Scheduling mode, frequency, the session-length branch and which question a carousel is on are all resolved server-side, so the client carries none of it. There is no hasUserFacingEvaluations-style flag to gate on either, so the endpoint is simply called on the two occasions a survey can appear: a session that has just ended (sessionId), and a project that has just opened (projectId). The project-open call is fire-and-forget and checks CurrentProjectKey before presenting anything, since another project can be opened while it is in flight.

Unlike evaluations, the survey is deliberately not part of a handover. RFC-29 asks for it only where the user is not mid-flow, and a handover already stacks the review and the incoming agent’s loading screen — the survey would be a third screen standing between the user and the agent they asked for.

The three ways out differ in what they submit, not in what the screen does:

  • Skip submits an empty answers array, which records the form as answered and retires it.

  • Continue submits only the questions that were answered — an unanswered one is left out entirely rather than sent as a null, every question being optional.

  • Later submits nothing at all, so the definition stays eligible and is asked again next time.

Skip and Continue are one button, reading ui.skip until something is answered, the way the evaluations done button already doubles as Skip. The submission is fire-and-forget: the user is never held behind a POST they could not retry anyway, and a failed POST is retried in the background like an issue report, and a definition whose retries all failed is simply offered again.

Worked example: leaving a conversation

The whole exit flow, in one readable method in AppOrchestrator, with the feedback survey and the evaluations wait as ordinary `await`s:

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

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

    await ShowSessionFeedbackAsync(session);

    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.

The reverse holds too: a build with the orchestrator’s GameObject disabled still gets past the Android native splash. NativeSplash releases the splash right after scene load unless something called NativeSplash.Hold() from Awake. The orchestrator does, and releases it itself once localization is ready. RuntimeInitializeLoadType.AfterSceneLoad runs after the scene’s Awake calls but before Start. Disabling only the component is not enough: Awake still runs on a disabled component, so the splash stays held.

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 enum: the other two ends it would encode do not belong on it. It carries only an optional Message - the engine’s text for a non-recoverable error, shown instead of the generic "conversation ended". 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.State → Promethist.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.

App → Promethist.UI

3 (P2)

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

App, Conversation, Authentication → UI.Popups; Conversation → UI.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.