Skip to content

Dev to staging for 3.9.0 release - #180

Merged
rymorale merged 57 commits into
stagingfrom
dev
Sep 29, 2026
Merged

rymorale merged 57 commits into
stagingfrom
dev

Conversation

@rymorale

@rymorale rymorale commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Description

  • Add data handoff APIs and forwarding to Brand Concierge, including rejection handling.
  • Add support for taller product cards containing up to 6 lines of description text.
  • Bug fixes: Anchor product card CTA to bottom, add focused input-field gradient styling, and fix listening waveform rendering.
  • Update documentation.

Related Issue

MAR-2833 / MAR-2835, MAR-2887, MAR-2991, MAR-3033

rymorale and others added 30 commits September 11, 2026 11:22
Staging to main for 3.8.1 release
Previously the SDK sent only the ECID to the conversation and feedback
endpoints, dropping any additional identities the customer set via
Identity.updateIdentities (e.g. hashedEmail, CRMID, custom namespaces).

Now the full EdgeIdentity identityMap is carried verbatim:
- ConciergeState stores the whole identityMap; ECID is derived from it
  and kept as the readiness signal (updateExperienceCloudId -> updateIdentity).
- The request/feedback bodies serialize the map via JSONObject (escaping
  arbitrary keys/values), replacing the hardcoded ECID-only block.
- Namespaces are forwarded as-is (no interpretation/relabeling); priority
  and identity-graph rules are configured server-side in AEP.
- Log namespace names only, never id values (PII).

Adds unit tests for the full-map, ECID-only (no regression), and empty-map
cases, plus auth-token coexistence, and documents the capability in the
implementation guide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JSONObject.toString() returns null rather than throwing on an
unserializable map, so the previous runCatching/getOrElse fallback was
never exercised (and would have emitted "identityMap": null). Replace it
with an explicit null-coalescing fallback to an empty object, and add
tests for the serialization-failure and empty-identityMap branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Back merge main to dev after 3.8.1 release
The `clear resets state to initial values` test populated identityMap
but never verified it was nulled after clear(). Add the missing
assertion so a future selective-reset refactor that forgets identityMap
is caught directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Matches the updated design doc: accept/reject is now the only confirmation signal,
and the localMessage field is accepted/decoded (rendering deferred).

Still to be implemented:
- Real forward to Brand Concierge — ConciergeChatService has no extra-XDM
  parameter yet; NotImplementedDataHandoffForwarder remains a stub
- localMessage chat rendering — accepted and decoded, but not yet appended
  to the chat transcript (needs a ConciergeStateRepository-style bridge into
  ConciergeChatViewModel)
- Ordering when a checkout-result turn and an ordinary chat message arrive
  around the same time — both currently share ConciergeChatService's single
  request slot
Wrap the existing data-handoff event/response mechanism in a typed
Concierge.sendDataHandoff(routingHint, xdmFields, localMessage, completion)
call so host apps no longer
build/dispatch the Event or parse the response themselves. The old
ConciergeDataHandoffEvent public surface is now internal wire-format
plumbing behind this API.
Report ConciergeDataHandoffRejectReason (a typed enum) from
Concierge.sendDataHandoff's completion instead of a raw wire-format
String, and route data-handoff request/response events through the
standard EventSource.REQUEST_CONTENT/RESPONSE_CONTENT (matching Edge,
EdgeIdentity, and EdgeConsent) instead of a dedicated custom source,
disambiguating by event name in the shared request-content listener.
The Identities section re-documented the Edge Identity updateIdentities
API, which belongs to the identity extension's docs. Replace the inline
sample with a link to the official Edge Identity API reference so there
is a single source of truth; keep only the Brand Concierge-specific
behavior (verbatim forwarding, ECID auto-included, server-side rules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forward accepted data-handoff events asynchronously through the Brand
Concierge conversation service without rendering backend responses in chat.

Merge caller-provided XDM fields into the outbound request while preserving
the SDK-managed identity map, and isolate forwarding failures from the
event accept/reject response.

Document Android data-handoff integration requirements and add coverage for
forwarding, XDM serialization, unavailable surfaces, and failure handling.
… fixes

Address PR #172 review comments and two follow-up review passes on the
Brand Concierge forwarding path in ConciergeChatViewModel:
- Release the data-handoff exclusivity reservation immediately on both
  session deactivation and delivery timeout, instead of leaking it until
  a stale request drains through the queue (was causing spurious
  CHAT_IN_PROGRESS rejections on retry).
- Add a catch-all around the shared chat/handoff processing coroutine so
  a synchronous exception from the conversation service can no longer
  permanently kill it.
- Clean up UI state (placeholder + Processing) on every cancellation
  path, not just timeouts.
- Reject reserved XDM keys (e.g. identityMap) at the network-client
  boundary as defense-in-depth beyond the existing event-level check.
- Warn when a second chat session overwrites an already-active data
  handoff forwarder instead of silently dropping it.
- Remove dead code: an unreachable ERROR-state branch that was also
  triple-wrapping the error message, a redundant CAS retry loop, and a
  concurrent Set standing in for what's always a single in-flight
  handoff.
- Extract a shared helper for the repeated "rethrow cancellation,
  otherwise report" error-handling shape.
Data-handoff failures (timeout, service error, or an error frame) no
longer inject a generic error bubble into the chat transcript - the
outcome is reported only through the sendDataHandoff completion callback,
matching how the feature is surfaced everywhere else. Ordinary user-typed
chat still renders its error bubble unchanged (streamConversation gains a
renderErrorsInChat flag).

Placeholder cleanup now lives entirely inside streamConversation, where
the turn's placeholder provably exists (including on timeout/deactivation
cancellation), so processDataHandoffRequest's catch blocks only reset
state. This fixes a case where a failed handoff with no placeholder could
remove a prior turn's assistant message.

Adds coverage for the end-to-end handler->forwarder->view-model wiring,
the accept/reject response event's inResponseToEvent correlation, silent
handoff failure/timeout/error-frame paths, and removal targeting (a
failed or timed-out handoff must not delete a prior turn).
…assthrough

Concierge identityMap passthrough
The upstream/dev merge (PR #168) added full identityMap passthrough via
identityMapJson(), but only wired it into the feedback request body.
createXdmObject (used by chat and data-handoff requests) still built its
own bespoke ECID-only identityMap inline, so it never picked up the
passthrough behavior — breaking 4 ConciergeConversationServiceClientTest
cases after the merge.

createXdmObject now reuses identityMapJson(state), the same helper the
feedback path already relies on.
ConciergeConversationSession now owns the request queue, the shared
CHAT_IN_PROGRESS gate, the ConversationService, and the observable
messages/state flows. ConciergeChatViewModel becomes a renderer: it
delegates those flows and routes user actions into the session, keeping
only UI concerns - input state, welcome card, feedback, STT, links, and
UI tracking.

Because the session outlives the composable and the ViewModel, a data
handoff fired while the chat is hidden or closed is now accepted,
forwarded, buffered into the transcript, and painted when the chat is
next shown. The NO_ACTIVE_SESSION rejection is gone from the enqueue
path; CHAT_IN_PROGRESS, EMPTY_RESPONSE, DELIVERY_FAILED and
DELIVERY_TIMEOUT are unchanged. ActiveConciergeDataHandoffForwarder and
the composable's activate/deactivate wiring are deleted in favor of
SessionDataHandoffForwarder.

Supporting changes:

- ChatScreenState no longer carries the feedback dialog. It is
  per-renderer state on the ViewModel, so a thumbs-up on one chat
  surface no longer opens a dialog on another.
- conversationId follows the backend instead of pinning to the first
  value seen, which would otherwise tag later turns and their feedback
  with a conversation the 30-minute session roll had already ended.
- The session exposes shutdown() for privately owned instances (the
  debug demo, tests). It drains queued handoffs so their callers are
  never left without a completion, and refuses to tear down the process
  session. The shared session is deliberately left running in onCleared.
- buildCardElementDict moves to a shared util.

Public API is unchanged: Concierge.sendDataHandoff,
ConciergeDataHandoffCallback and ConciergeDataHandoffRejectReason are
untouched.
Reverts 3dc674d ("move the conversation onto a process-lifetime
session") back to a session-scoped ConciergeChatViewModel, restoring
the NO_ACTIVE_SESSION rejection. A process-lifetime transcript
survives logout/consent withdrawal with no reset hook, merges turns
across surfaces, and grows unbounded - the reviewer's stated privacy
concern. Buffering a handoff fired while chat is hidden remains a
product option, but belongs in its own PR.

On top of the revert:

- Data handoff failures now render a generic error bubble like any
  other failed turn, instead of failing silently in the transcript.
- A failed/cancelled/timed-out turn rolls back everything it added -
  not just the last message - via truncateMessagesTo(turnStartIndex),
  so a multi-message turn (card + CTA) doesn't leave partial content
  on screen after the caller is told it failed. Unified into a single
  atomic transcript update and applied identically to chat and
  handoff turns.
- DataHandoffDeliveryResult.Failed carries an internal-only message
  with the underlying failure detail, logged for debugging but never
  exposed on the public ConciergeDataHandoffCallback (a fun interface;
  adding a parameter there would be a breaking change).
- A missing or blank routingHint is accepted as an empty service
  query instead of rejected, since xdmFields alone can carry enough
  routing context - matching iOS. MISSING_ROUTING_HINT is removed
  outright. A
  present-but-null routingHint is still rejected as
  INVALID_ROUTING_HINT_TYPE, distinct from an absent key.

Also fixes two bugs the wholesale revert would otherwise have
reintroduced, both called out by name in 3dc674d's own commit
message as deliberate fixes bundled into that commit:

- An open feedback dialog no longer closes when an unrelated turn
  transitions to Processing/Error/Idle.
- conversationId now follows the backend instead of pinning to the
  first value seen, so a session rolled over after 30 idle minutes
  doesn't tag later turns and feedback with a stale id.

Adds test coverage for buildCardElementDict's known-key allowlist,
lost when the revert deleted its dedicated test file.
Once a handoff is accepted into the chat, a service error, an empty
response, and a delivery timeout all now roll the turn back and render
the same generic error message a failed chat turn renders. Previously
only DELIVERY_FAILED painted a bubble, so a timed-out or empty handoff
left localMessage sitting in the transcript with no reply and no error -
the host had to render its own UI for two of the three failure modes but
not the third. Matches adobe/aepsdk-concierge-ios#164.

Deactivating the session mid-flight still rolls back silently, since
there is no longer a chat surface to show an error on.

Also in this pass:
- Add the shared-processor catch-all the PR description already claimed.
  A throwing host completion callback could kill the single processor
  coroutine and strand the queue forever.
- Mark the chat busy synchronously when a handoff reserves the queue,
  closing the window where a chat message could silently queue behind an
  in-flight handoff for up to the delivery timeout.
- Cancel the timeout job when the request is already complete.
- Document which reject reasons render in chat so host apps do not
  double-render an error.
…and stop treating an empty response as an error

enqueueDataHandoff marks the chat busy when it reserves the slot, but
processDataHandoffRequest's two early returns - an already-completed
request, and a session that went inactive - returned without clearing
it, and deactivateDataHandoffSession does not touch _state either.
Dismissing the chat after enqueuing a handoff but before the processor
dequeued it therefore left the composer disabled for the rest of the
ViewModel's life, including after reopening the chat.

Separately, routing EMPTY_RESPONSE through handleConversationError
overshot iOS. There the empty-response branch renders its own copy and
deliberately dispatches no errorOccurred, because nothing failed - the
request was delivered and answered, there was just nothing to show.
Only the stream-error branch tracks, which a timeout also unwinds
through. Split renderTurnMessage out of handleConversationError so the
empty case can render without the error telemetry, and give it the same
distinct copy iOS uses.
rymorale and others added 27 commits September 21, 2026 20:17
Render a handoff's localMessage as an agent message rather than a user
message. App code fires the handoff after a real-world event, so
attributing it to the user misreads the transcript and makes the
recommendations that follow look like a user request.

Split the single 90s handoff cap into a 10s first-chunk cap and a 60s
turn ceiling. A flat cap cannot both fail a wedged backend quickly and
give a slow-but-healthy response room to finish. The first-chunk cap is
disarmed by the first streamed chunk. Drop the network read timeout to
15s so it sits below the turn ceiling instead of above it.

Default routingHint to an empty string for callers whose XDM fields
alone determine routing.
closeConcierge() tore down the data handoff session, but there is no
matching re-activation in openConcierge() — activation lives only in the
DisposableEffect inside the ConciergeChat composable.

In dialog mode this was harmless: closing removes the chat from
composition, so onDispose deactivates anyway. In direct-Compose and
ConciergeChatView embedding the chat stays composed across the call, so
the effect never re-runs and every later handoff was rejected with
NO_ACTIVE_SESSION for the life of the ViewModel, with no recovery path.

Make the DisposableEffect the sole owner of the session lifetime, with
onCleared() as the backstop. That is the only signal that is correct in
every integration mode, since it tracks whether a chat surface is
actually rendered rather than whether the dialog flag is set.
Keep handoff failures out of the transcript while preserving local messages.
Start the first-response timeout after request preparation and terminate streams
immediately on error frames. Update tests and documentation for the new behavior.
The mock conversation service always succeeded, so the demo could not show
the behaviour this work is actually about: a handoff that fails after it was
accepted renders no error UI, leaving localMessage on screen with nothing
after it. The existing invalid-payload preset only covers pre-acceptance
validation, which never rendered anything in the first place.

The mock now branches on routing hint via MockDataHandoffRoutingHints:
demo-stream-error emits an ERROR frame (DELIVERY_FAILED), demo-empty-response
completes with nothing renderable (EMPTY_RESPONSE), and demo-silent-service
stays quiet past the first-chunk cap (DELIVERY_TIMEOUT). Each has a demo
preset that sends a localMessage, so the silent-failure case is what shows on
screen. The presets are mockOnly and drop out of the button list when the mock
switch is off, since the live service cannot be asked for those outcomes.

Also drops the reject-reason table's "Renders in chat" column, which now reads
No for all 14 rows and no longer distinguishes anything - the paragraph below
the table states the rule once - and sorts two coroutine imports.
Make the auth-token saturation test wait for submission, not a clock

The test filled the executor pool and its bounded queue with 20 blocked
resolveToken() calls, then asserted a 21st was rejected fast. It waited
deterministically only for the 4 pool threads and gave the 16 queued
submissions a flat 200ms. Under load one of those submits could land late,
leaving a queue slot free: the measured call was accepted instead of rejected
and waited out its own 5s timeout - still null, so the null assertion passed
and only the fail-fast bound broke.

Wait for every saturating caller to report TIMED_WAITING instead. A caller
parks in Future.get(timeout) immediately after submit() returns, so that state
means its task has landed; an untimed park, which is what queue-lock
contention shows, no longer opens the gate early. Bounded at 5s and asserted,
so a real hang still fails with a clear message.
…vice

[MAR-2835] Forward data handoff to BC service
The recording waveform was drawn inside the mic IconButton, whose
content is clipped to a circle, so the outer bars were cropped. Draw
the waveform as a sibling of the button instead. Taps still reach the
button.

Shape the bars to follow a fixed height profile
(3, 7.88, 6, 10.5, 7.5, 3), resampled when barCount differs, and
change the default bar count from 5 to 6.

Size the waveform gradient to each bar rather than the whole canvas,
so short bars show the full gradient instead of only mid-tones.

Add unit tests for the bar height profile and an instrumented test
that checks each bar is drawn with the full gradient.
Carousel cards are sized to the tallest card, but Card's built-in
content wrapper dropped that height, so CTAs on shorter cards sat
partway down the card. Replace the outer Card with a Surface, which
passes the card height through to its content, and add a weighted
spacer so the CTA row sits at the bottom. Content taller than the card
still scrolls.

Add a UI test checking that CTAs line up with the card's bottom
padding when content is short.
…-on-listening-waveform

[MAR-2991] Fix clipped listening waveform and match bar shape
Move the price column out of the text column so it sits after the
weighted spacer, keeping the price and CTAs together at the bottom of
the card.

Add tests for:
- the price anchoring to the bottom when there are no CTAs
- the price anchoring to the bottom when there is no "was" price
- prices and CTAs lining up across cards with different subtitles
- CTAs lining up when only one card has a "was" price
…ard-cta

[MAR-3033] Anchor extended product card CTAs to the bottom of the card
Use the configured input outline gradient while the field is focused, applying the focus outline width. Fall back to the solid focus color when no renderable gradient is configured. Draw the gradient after the Surface content so the background doesn't obscure the border, and add an instrumentation test verifying the focused gradient renders across the border.
Render input border gradients when focused
- Add configurable description limits to extended product cards. The new `--product-card-description-max-lines` option defaults to two lines and supports unclamped descriptions with `none` or invalid values. Carousels reserve CTA space across cards to keep prices aligned.
- Update the product card demo with 2-, 4-, and 6-line descriptions and a longer sample to demonstrate ellipsis.
- Add config and UI tests, and document the theme option.
Add tests for:
- product-card-description-max-lines keeping existing layout values
- descriptionMaxLines defaulting to 2 and being set by the theme
- the reserved CTA placeholder being hidden from accessibility and not
  tappable
- the reserved CTA placeholder still taking up space on the card

Fix productCarousel_reservesCtaSlotAcrossExtendedCards, which failed
because the carousel's hidden sizing copy of each card also matched the
price text. The test now reads only the visible card.
- Align `--product-card-description-max-lines` parsing with iOS by ignoring invalid values, clamp directly configured nonpositive line counts to prevent Compose failures, and memoize carousel CTA-slot detection.
- Update the style guide and add regression coverage for mapper behavior and invalid direct theme values.
…pport

[MAR-2887] Add support for taller product card descriptions
@rymorale
rymorale requested a review from cdhoffmann September 29, 2026 20:50
@rymorale
rymorale merged commit 0a0751d into staging Sep 29, 2026
10 checks passed
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.

3 participants