feat(jvmessenger): smarter & more engaging popup chat (phases 1-2) - #128
Closed
eldonm wants to merge 11 commits into
Closed
feat(jvmessenger): smarter & more engaging popup chat (phases 1-2)#128eldonm wants to merge 11 commits into
eldonm wants to merge 11 commits into
Conversation
added 11 commits
July 25, 2026 08:34
Phase 1 of the "smarter & more engaging" work. Converts data the server already
sends into visible intelligence, and makes the launcher an engagement surface.
Stream handling (new pure reducer, src/app/streaming/reducer.ts + tests):
- Honour `segment_id`. The server mints it to separate distinct replies within a
turn; the client concatenated them, running an adhoc notice straight into the
model's answer with no break.
- Route `thought_type` instead of flattening all four subtypes into one string.
`reasoning` still accumulates as maskable text, while tool_call/tool_result/
status now drive a live activity strip ("search products…") that works even
when reasoning is masked.
- Stop actually aborts: create an AbortController and pass `signal` (sseClient
already honoured it) and wire `onCancel`. An aborted turn is not an error.
- Regenerate works: implement `onReload` (replay the preceding user turn).
- Errors are an ephemeral banner with Retry, not assistant content — previously
they were inline emoji text that persisted into history and got read aloud by
TTS. An empty failed bubble is dropped rather than left blank.
Launcher (loader-side, works before the iframe exists):
- Labelled pill (avatar + agent name), dismissible teaser card, and an inline
mini-composer so a visitor can start typing without opening the panel.
- Text typed there opens the panel and is sent as turn one, via a new `prefill`
bridge message queued until the app posts `ready`.
- Dismissal persists on the host origin for `data-teaser-cooldown-days` (7 by
default) so we never nag.
- Entrance/attention animations, all disabled under prefers-reduced-motion, plus
dark-mode styling.
- New options: `data-teaser`, `data-teaser-delay`, `data-teaser-cooldown-days`.
A tapped chip sends its label verbatim, so a suggestion carrying a value only
the user can supply is broken. "Company name is XYZ" would literally send "XYZ";
"My budget is $500" asserts a number the user never gave.
- Prompt now states the verbatim-send constraint explicitly, forbids invented
values and placeholders, and prefers intent over data ("It's for my company"
works as a button; "My company is Acme" does not).
- Quality over quantity: `num_suggestions` is now a maximum, not a target. The
model is told an empty array is a good answer — especially right after the
assistant asked for information only the user can give — and the action
publishes nothing rather than padding with filler.
- New `needs_user_specifics()` structural filter as the safety net: bracketed /
blank / stand-in placeholders (XYZ, ABC123, "your company", "e.g."),
"<subject> [noun] is <value>" declarations, and invented currency amounts.
Verified against the reported case: a turn where the assistant asks for company
name and a preferred date now yields a single usable chip instead of three, with
no placeholder among them.
Phase 2. The agent could already speak between turns
(Agent.send_proactive_message, TaskMonitor) — the browser just had no open ear,
because the interact SSE stream is turn-scoped and closes with the walk. jvagent
already exposes a long-lived subscription that accepts the same Mode B session
token, so this is a client plus two server fixes, not a new endpoint.
Server (jvagent/action/response/streaming.py, reply/endpoints.py):
- `stream_messages` gains `keepalive_seconds`: emit an SSE comment while idle so
nginx/ALB/Cloudflare don't drop a parked connection at 60-100s. It doubles as
the queue poll interval — the old fixed 0.1s poll is 10 wakeups/sec per idle
client, fine for a turn but wasteful per parked tab.
- `stream_messages` gains `max_replay`: streaming subscribers never drain the
session queue, so an unbounded backlog re-sent everything on every reconnect.
- The subscribe endpoint passes both (20s keepalive, 50-message replay).
Client (jvmessenger):
- New `streaming/channelClient.ts`: fetch + ReadableStream (EventSource can
neither POST nor set headers), sharing `parseSSEBuffer` with the interact
client. Jittered backoff, and its own token-refresh timer — the token was only
refreshed inside `runTurn`, so an idle tab held a dead token and 401'd.
Note the subscribe stream frames messages **bare**, not in the interact
stream's `{type, message}` envelope; the client accepts both.
- Ingestion dedups on server message id, persisted (`seen:` key), because the
backlog replays on every reconnect and the interact stream delivers the same
messages again. The channel is also suspended while a turn runs, since one bus
feeds both.
- The unread badge is finally called: `bridge.notify` was wired end-to-end but
had no caller. The host now states visibility explicitly on `ready` — the app
assumed it was visible, which cleared the badge on the hidden-iframe path.
- New `data-proactive` (default off): creates the iframe hidden at boot so the
app can subscribe before the panel is ever opened. Off by default because it
loads the bundle on every page view, and because proactive delivery currently
requires a single-worker deployment (the response bus is process-scoped).
Phase 3, client side complete; the server hand-off has a known gap (below). Loader (new `loader/pageContext.ts`): - Captures what the loader alone can see: origin, path, title, referrer origin, dwell, max scroll depth, and a visit counter for returning-visitor detection. - **Privacy:** query strings and hashes are dropped — they routinely carry emails, tokens and order ids. Referrer is reduced to its origin. - Behavioural triggers for the teaser (`delay | scroll | exit | idle`), first match wins, once per page. Never fires while the visitor is typing in a host form field, and still respects the existing dismissal cooldown. - New options: `data-teaser-triggers`, `data-teaser-scroll-percent`, `data-page-context`. Transport: - New `context` bridge message; the host replays the latest snapshot on `ready` and refreshes it periodically, so a late-booting app is not left blind. - The app forwards it on each turn as `data.page_context` — the structured lane already used for attachments. The client draws no conclusions from it (thin-harness: no client-side intent classification). New core action `jvagent/page_context`: - `visitor.data` is never surfaced to the model (only `image_urls`, via the vision reflex), so context would otherwise arrive and be ignored. This action renders it as one factual line and contributes it as a response-shaping parameter, mirroring IntroInteractAction. - Deliberately states facts only — no advice, no suggested next step. A test pins that boundary, since anything prescriptive would be turn-prep steering. KNOWN GAP: the action registers and executes (verified at weight -250), but the model still reports no knowledge of the page, so the parameter is not reaching reply composition. Needs a follow-up on how the responder consumes walker parameters versus the orchestrator's egress path. Everything up to and including the action running is verified; the last mile is not.
The action registered and ran, but the model still answered "I don't have access to your current page". Two mistakes, both about the parameters subsystem: 1. Parameters are *conditional response rules* — `render_parameters` emits them as "When <condition>: <rule>". Contributing an unconditional context blob reads as a style note and gets ignored. 2. More importantly, **scope**. Response-scoped parameters shape the *responder*, and the Orchestrator's literal `reply` path can skip that compose entirely, so the model never sees them while reasoning. Orchestration scope puts the context in the agentic loop prompt, which is where the model actually decides what to say. Verified with a control: given the same question, the agent answers "You're currently on the Pricing page" when `data.page_context` is supplied, and invents a page name when it is not.
Phase 4, client side. The frontend owns a fixed catalog; the agent only names a
component and supplies data via `metadata.ui`. A model can never inject markup
or layout, only fill in a shape we defined.
- `metadata.ui` envelope `{v, component, id, props, fallback}`, accepted as an
object or an array. One namespaced key rather than sibling top-level keys,
because `visitor.data` is merged over action metadata server-side — one
reserved name is one name to defend.
- `fallback` is load-bearing: it renders on version skew, an unknown component,
or a malformed payload, and is what appears in the downloaded transcript.
Malformed entries are dropped, never thrown — a bad payload must not break a
turn.
- Components accumulate in the turn reducer (deduped by id) rather than being
replaced like suggestions, since each is a distinct thing the agent showed,
and render *after* the text so reading order matches intent.
- Registered through `MessagePrimitive.Parts components={{tools:{by_name,
Fallback}}}` — the shape confirmed by spiking the installed assistant-ui
before committing to it.
- `card`: title/subtitle/body/image/field-grid/actions. `choices`: single-select
sending the label verbatim, same contract as the suggestion chips.
- Link hrefs are allowlisted to https/mailto/tel, so a `javascript:` URL from
retrieved content can't ride in on agent-supplied props.
Verified in the browser: card, choices (incl. description + disabled option),
and an unknown component degrading to its fallback text.
Server emitter (`ui__render` + deferred flush) is NOT built yet — nothing
publishes `metadata.ui` today, so this ships inert until that lands.
Everything added in phases 1-4 was undiscoverable: not one of the new `data-*` options appeared in the docs, and `sound` had been missing since it shipped. - Options table: `sound`, `teaser`, `teaser-delay`, `teaser-cooldown-days`, `teaser-triggers`, `teaser-scroll-percent`, `page-context`, `proactive`. - New sections for the launcher teaser (incl. the trigger table), page context (with the privacy note on dropped query strings), proactive messages (leading with the single-worker caveat), and agent-driven UI components. - The UI-components section is explicit that no server emitter ships yet, so nobody expects `metadata.ui` to populate itself. - actions-catalog: row for `jvagent/page_context`. ADR-0036 records the decisions ADRs are supposed to hold, since 0035 is immutable: reusing `reply/subscribe` rather than adding an endpoint; proactive opt-in and why sticky sessions do NOT fix the multi-worker gap; id-dedup; page context on `data.page_context` with PII stripped; why the context parameter must be orchestration-scoped (the Orchestrator's literal `reply` path can skip the responder compose entirely); static generative UI over declarative/HTML; and why an empty metadata-only publish does not trip ADR-0024's latch. It also records what is NOT built — the `ui__render` emitter, the trust surface, identity/continuity, and the stale "Not a chat UI" non-goal in PROJECT.md — so the gaps are on the record rather than in my head.
Server half of Phase 4. New core action `jvagent/ui` publishing a model-callable `ui__render` tool plus the flush that publishes what it stages. - **Stage, then flush.** A tool cannot emit UI by returning it — `ToolResult` forwards only `content`, never metadata. And publishing from inside the tool would put the component on the wire *before* the assistant's sentence. So the tool stages an envelope and `execute()` (weight 90 — after the Orchestrator, before Suggestions) flushes it once the reply exists. - **One class, not two.** An action package declares a single `archetype`, so a separate flusher class in the same module is never instantiated by the loader — found the hard way when it silently never ran. - **ADR-0024 safe.** The flush publishes empty `category:"user"` content, and `mark_emitted` is gated on non-empty content, so the egress latch is never tripped. Same shape as SuggestionsInteractAction. - **Staged on a walker attribute, never `visitor.data`** — that dict is merged into the metadata of every published message, so staging there would leak the envelope onto every frame of the turn. - Validates against the catalog, requires `fallback` (without it the component is invisible off-web, in transcripts, and to screen readers), caps payload size and components per turn, and refuses on non-streaming channels rather than posting a blank message to a channel adapter. 14 tests cover envelope validation and the flush contract (empty content, metadata-only, non-streaming skip, queue cleared so it can't double-publish). KNOWN GAP: the action loads and runs at weight 90 (verified in the action_executed trace), but the model does not reliably call `ui__render` on the leadgen agent — `find_tool` shows it searching for the name. This looks like lean tool surfacing (ADR-0018) keeping long-tail tools off the default surface. Reaching it likely needs a skill SOP pinning it via `allowed-tools`, or surfacing config. Not yet verified end-to-end from a model-initiated call.
Two fixes, the first correcting a wrong diagnosis in the previous commit.
1. **The tool was never on the surface.** I attributed this to lean surfacing
(ADR-0018); that was wrong. `InteractAction.get_tools()` returns `[]` for an
`always_execute` action — that path exists to expose *routable* IAs as intent
tools, which this is not. UiAction needs both halves: a tool on the surface
and an `execute()` that always runs to flush. It now collects its decorated
tools directly via `collect_tools(self)` instead of inheriting the routing
behaviour. Verified: `get_tools()` returns `['ui__render']`, and an envelope
now reaches the wire on a live turn.
2. **Empty component shells are rejected.** The model's first successful call
passed `props: {}` and put everything in `fallback`, which renders as plain
text — indistinguishable from just replying. `build_envelope` now requires
`options` for choices and at least one content key for card, and names the
missing key so the model can retry rather than silently degrade.
Also pins `ui__render` on the leadgen agent's orchestrator so components stay
callable turn-1 without disabling lean surfacing for everything else.
17 tests.
KNOWN GAP: the transport is proven end-to-end (tool called → envelope published
→ `metadata.ui` on the wire), but on this agent gpt-4.1 does not reliably supply
`props` — it repeats the call, gets the validation message, and falls back to
text. Making components land consistently needs prompt/SOP work (a `render_ui`
skill with worked examples), not more plumbing.
The ui__render plumbing worked but the model wouldn't use it properly: it called the tool with empty `props`, put everything in `fallback`, got the validation message back, and gave up to plain text. That was never a plumbing problem — the model had no worked example of the props shape. A JV skill fixes it, which is where this judgment belongs (thin-harness: SOPs carry the judgment, actions carry the capability): - when to reach for `choices` vs `card`, and when NOT to render at all - full worked JSON for both components - `value` is what gets sent on tap, so phrase it as something the visitor would say - `fallback` is a real sentence, not a placeholder — it is what non-web channels, the transcript and screen readers get - after rendering, add one framing sentence and stop; don't restate the component - how to read a `not_rendered` reason and retry once `always-active: true` pins its allowed-tools so the component stays callable turn-1. Verified end-to-end: "Show me the plan options as selectable choices" now produces a choices envelope with prompt + three fully-populated options (label, value, description) on the wire.
CI's isort reordered an import my local run had passed — the local pre-commit cache was stale and false-passing. Re-ran with a cleaned cache; all hooks pass.
eldonm
pushed a commit
that referenced
this pull request
Jul 25, 2026
…estrator cost branch Stacks #129 on top of #128 so the orchestrator work can be exercised through the jvmessenger popup chat, which is where the engagement surface and the executive loop actually meet. Clean merge -- the two changesets have no overlapping files. Until #128 lands on main, #129's diff shows both.
Member
Author
|
Closing as rolled into #129 ( Verified:
Continue review / merge on #129. |
Member
Author
|
Superseded by #129 — full engagement/messenger work already on that branch. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes the embeddable messenger anticipate, show its work, and speak first — grounded in an audit of what the server already sends but the client threw away, plus research on what modern support widgets actually do.
Phase 1 — make the existing wire honest
New pure reducer (
streaming/reducer.ts, 15 tests) turning data we already receive into visible intelligence:segment_idhonoured. The server mints it to separate distinct replies in one turn; the client concatenated them, running an adhoc notice straight into the model's answer.thought_typerouted, not flattened.tool_call/tool_result/statusnow drive a live activity strip that works even when reasoning is masked.AbortController+signal(whichsseClientalready honoured) andonCancel. An aborted turn is no longer an error.onReloadwas rendered but inert.Launcher engagement surface
Labelled pill, dismissible teaser card, and an inline mini-composer — all in the Shadow-DOM launcher, so they work before the iframe exists. Text typed there opens the panel and sends as turn one (new
prefillbridge message, queued until the app isready). Dismissal persists per host origin (data-teaser-cooldown-days, 7 default). Entrance/attention animations, all disabled underprefers-reduced-motion.New:
data-teaser,data-teaser-delay,data-teaser-cooldown-days.Phase 2 — persistent session channel
The agent could already speak between turns; the browser had no open ear (the interact stream is turn-scoped). jvagent already exposes a long-lived subscription accepting the same Mode B token — so this is a client plus two server fixes.
Server:
stream_messagesgainskeepalive_seconds(proxies drop silent streams at 60-100s; also replaces a 0.1s poll that burned 10 wakeups/sec per idle tab) andmax_replay(streaming subscribers never drain the queue, so reconnects re-sent everything).Client: new
channelClient.tssharingparseSSEBuffer; persisted id-dedup; channel suspended during turns (one bus feeds both); its own token-refresh timer (the token only refreshed insiderunTurn, so idle tabs 401'd). The unread badge is finally called —bridge.notifywas wired end-to-end with no caller.New:
data-proactive(default off — loads the bundle on every page view, and delivery needs--workers 1today since the response bus is process-scoped).Suggestion tuning
A tapped chip sends its label verbatim, so "Company name is XYZ" literally sends "XYZ". The prompt now states that constraint, bans invented values/placeholders, and treats
num_suggestionsas a maximum —[]is a valid answer.needs_user_specifics()is the structural safety net.Verification
Verified live end-to-end: multi-turn chat, teaser → send → turn one, proactive push badging the closed launcher (5) then rendering on open, and the reported suggestion case now yielding one usable chip instead of three placeholders.
Gate:
pre-commit run --all-filesclean, 30/30 frontend tests, backend slices pass.Full design (including the resolved
metadata.uicomponent protocol for Phase 4) is in the plan file.