Skip to content

feat(jvmessenger): smarter & more engaging popup chat (phases 1-2) - #128

Closed
eldonm wants to merge 11 commits into
mainfrom
feat/jvmessenger-smarter-engagement
Closed

feat(jvmessenger): smarter & more engaging popup chat (phases 1-2)#128
eldonm wants to merge 11 commits into
mainfrom
feat/jvmessenger-smarter-engagement

Conversation

@eldonm

@eldonm eldonm commented Jul 25, 2026

Copy link
Copy Markdown
Member

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_id honoured. 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_type routed, not flattened. tool_call/tool_result/status now drive a live activity strip that works even when reasoning is masked.
  • Stop actually abortsAbortController + signal (which sseClient already honoured) and onCancel. An aborted turn is no longer an error.
  • Regenerate worksonReload was rendered but inert.
  • Errors are an ephemeral banner with Retry, not assistant content. They previously persisted into history and were read aloud by TTS.

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 prefill bridge message, queued until the app is ready). Dismissal persists per host origin (data-teaser-cooldown-days, 7 default). Entrance/attention animations, all disabled under prefers-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_messages gains keepalive_seconds (proxies drop silent streams at 60-100s; also replaces a 0.1s poll that burned 10 wakeups/sec per idle tab) and max_replay (streaming subscribers never drain the queue, so reconnects re-sent everything).

Client: new channelClient.ts sharing parseSSEBuffer; persisted id-dedup; channel suspended during turns (one bus feeds both); its own token-refresh timer (the token only refreshed inside runTurn, so idle tabs 401'd). The unread badge is finally called — bridge.notify was wired end-to-end with no caller.

New: data-proactive (default off — loads the bundle on every page view, and delivery needs --workers 1 today 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_suggestions as 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-files clean, 30/30 frontend tests, backend slices pass.

Full design (including the resolved metadata.ui component protocol for Phase 4) is in the plan file.

Eldon Marks 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.
@eldonm

eldonm commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Closing as rolled into #129 (perf/orchestrator-loop-cost).

Verified:

Continue review / merge on #129.

@eldonm

eldonm commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Superseded by #129 — full engagement/messenger work already on that branch.

@eldonm eldonm closed this Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant