Skip to content

chore(sync): sync to upstream LibreChat v0.8.8-rc1 - #365

Merged
garfiec merged 44 commits into
developfrom
chore/sync-upstream-v0.8.8-rc1
Aug 19, 2026
Merged

chore(sync): sync to upstream LibreChat v0.8.8-rc1#365
garfiec merged 44 commits into
developfrom
chore/sync-upstream-v0.8.8-rc1

Conversation

@garfiec

@garfiec garfiec commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Summary

Brings the client to the official LibreChat v0.8.8-rc1 tag (previously v0.8.7 +dev.91adcf3f). Most of the change is wire-protocol parity — reshaped streaming activity phases, multi-question ask_user_question pauses, MCP OAuth/status fields, typed server errors — plus the rc1 upload MIME set. It also adds text quoting in chat and replaces the two-state backend version gate with a three-state one that can tell an unplaceable server from a proven-old one.

Changes

Streaming and message shapes

  • Activity phases are scoped by their exclusive end index, so a parent phase covers only the steps it owns and its label no longer leaks into batch grouping.
  • A text content part decodes both the plain-string and the annotated {value, annotations} form.
  • New passthrough fields on conversations, agents, /api/config and MCP connection status; agent_ids and phase on content parts.

Errors

  • ServerErrorCode / StreamErrorType classify typed server errors into actionable messages through a single entry point. In-band error content parts go through the same path instead of rendering as raw JSON in the thread.

HITL (ask_user_question)

  • A pause can carry up to four questions, answerable from the card or from the composer — one send per question, with drafts shared between the two surfaces.
  • The tool picker gates its plugin list and the Ask User row on the agents endpoint's capabilities, matching the web builder: a server with the capability off no longer offers a tool its runtime will drop.
  • The pending-action card expires when its deadline passes, and a sibling ask stays visible when the pause names no tool call.
  • Resume requests carry generationCreatedAt, so the server can reject a resume onto a replaced generation.

MCP servers

  • Editing an existing server now goes to PATCH /api/mcp/servers/:serverName; only creation still uses the create route.
  • A save rejected with MCP_OAUTH_SECRET_REENTRY_REQUIRED reports as an inline error on the client-secret field, which is the field the server is asking for, rather than as a generic failure.

Files

  • Shell-script MIME aliases and .potx are offered on rc1 servers behind version gates. Android's text/x-sh is normalized before upload, since upstream's alias list targets freedesktop/libmagic values and omits it.
  • The picker reacts to backend-version detection that lands after it opens.

Chat

  • "Add to chat" quotes selected message text into the next message; quotes carry through steer, queue and regenerate.
  • Copy serializes every part of a message, not just its text.
  • In-flight steer chips render right-aligned, as user-side turns.

Scrolling around a paused run

  • The streaming follower stands down while a run is paused for human review. A pause does not end the run, so it would otherwise keep pinning the list's tail every frame while the output it follows has stopped — chasing the pause card as it grows and carrying the question off screen. The one-shot scroll that brings a new card into view is unaffected.
  • The keyboard handler stands down whenever the list itself holds focus, leaving a newly focused node to foundation's own bring-into-view. The handler exists for the composer, which sits outside the list; running it for a field inside the list jumped to the tail of the last item and scrolled the user off the field they had just tapped.

Version gating

  • FeatureSupport (PRESENT / ABSENT / UNKNOWN) replaces the boolean gate where a wrong answer costs data. A dev build reports the last released version, so a plain version compare reads as "absent" — for the queued-attachment TTL touch that meant withholding the touch and letting the reaper collect an attachment out from under a queued send. Where support is discoverable, the client now probes once on UNKNOWN and latches the 404. supportsFeature is defined as featureSupport(...) == PRESENT, so gates that were not converted behave exactly as before.
  • BackendCommitMap regenerated across the full dev window.

Tooling and docs

  • check-mirrors.py handles the three declaration forms upstream writes and gains --self-test; the mirrors registry covers the new constants.
  • detekt excludes build-time codegen from analysis.
  • UPSTREAM_VERSION, backendTargetVersion, the README compatibility badge, DISCOVERY.md and VERSION_GATES.md updated.
  • A comment pass over the branch: explanations that had been repeated across several files are collapsed to one canonical home each, and comments that only restated the code are dropped. Wire-contract, data-loss and concurrency invariants are left in place.

Testing

  • assembleDebug, the unit-test suite (2322 tests, 0 failures) and detektMetadataCommonMain are green.
  • Android emulator (Pixel 10 Pro Fold, API 37) against ghcr.io/danny-avila/librechat:v0.8.8-rc1 in Docker: single and batched HITL asks end to end, the capability-off config variant, streaming with activity phases, message editing, model list, .sh upload, plus a regression pass over the existing chat and conversation flows.
  • GateProbeDeviceTest (instrumented, self-skipping without the rig) exercises the three-state gate against a real server in both route-present and route-missing modes.
  • MessageListPauseScrollInstrumentedTest (4 cases) covers the scroll behaviour above on a Pixel 9 Pro Fold, driving the frame clock by hand because the follower's per-frame loop never lets Compose go idle. Each assertion was checked against a build with the corresponding guard removed, so the two that pin this behaviour are known to fail without it.
  • iOS was not built or run — verification is Android-only.

Notes

  • Deliberate divergence kept: the streaming cursor stays; upstream's per-word fade-in is not ported.
  • iOS selection capture for quoting is not implemented — quote chips render, but nothing can be staged from iOS.
  • PendingAction.expiresAt is dormant upstream (the server never populates a TTL), so the expiry path cannot be exercised against a live server.

garfiec added 30 commits August 18, 2026 20:16
SYNC-01: the abort route now validates every target field before resolving
one, so the empty abortKey mobile sent before the created event assigns an
id is rejected 400 INVALID_ABORT_TARGET. Send conversationId="new"
instead, the placeholder the user-scoped fallback is gated on. Older
servers took that fallback unconditionally, so the new spelling is correct
against both. A 409 RUN_STILL_ACTIVE is retried rather than reported as a
failed stop.

SYNC-02: a run reconciled away ends with a frame the route rewrites to
event: error for protocol-v1 clients. The reply is already durable and the
existing terminal-error reload fetches it, but the user was shown an error
banner for it. Recognize the frame and end the stream without one.

SYNC-03: the status route answers 503 SERVER_NOT_READY with Retry-After: 1
while its generation epoch settles. All three call sites read that as "the
run is gone", so retry it in the repository rather than at each of them.

SYNC-08: a send whose parent response is still being saved now 409s, which
an immediate send after an abort reaches legitimately. Retried only when
the 409 carries no code — every other 409 on that route is coded and must
not be retried.
SYNC-04: a clarification can now arrive as up to four independently
answerable questions. The resume route branches on the payload carrying a
questions array — where it does, a bare answer string is rejected and an
answers map covering every question id is required. Mobile modelled only
the single-question shape, so a batched pause rendered question 1 and its
submit hard-failed, leaving the run paused with Stop as the only way out.

Render the batch as one form and submit the map keyed by the payload's own
ids. Skip resolves the whole batch with the declined sentinel per id, since
a local dismiss would leave the run paused until it expires.

SYNC-15: model tool_call_id on the interrupt payload and drop only the
paused call from the streaming tool cards. A sibling ask call in the same
turn is genuinely still running and keeps its card.
SYNC-05: activity_label gained an activity_label_type discriminator, where
absence still means the per-batch label and "phase" means a parent phase.
A phase part is appended at the END of the content array while its own
activity_start_index names where the phase began, so its position carries
no scope — but grouping treats any filled label as the header of everything
unclaimed before it, so a trailing phase label claimed the reply's answer
text or rendered as a stray sentence beneath it.

Skip phase labels in grouping. Nested collapsible phase groups are the
parity fix and are not attempted: upstream re-anchored the bounds twice
after the original PR (#14729, #14741), and skipping renders exactly what a
server without the feature renders.

SearchMatchEnumeration skips them too — that walk and the render walk are
lockstep, so a part counted but not drawn shifts every later match.

SYNC-19: model phase on text parts and the phase-bound fields on the label.

Tests mutation-checked: breaking the discriminator fails four of them.
SYNC-07: the tool cache and the registry inspector now build tool keys as
<toolName>_mcp_<normalizeServerName(server)>, but GET /api/mcp/servers
still advertises the raw configured name. Mobile split on _mcp_ and wrote
the raw name back, so a server named 'Google Workspace' produced a key no
producer honours — 'Tool not found' at execution, with every per-tool
option silently inert. Port the normalizer, normalize on write, and resolve
a normalized name back to its raw form on read so display and matching are
unaffected. Normalizing is a no-op for any name that already worked.

SYNC-09: register the Anthropic document allowlist as a mirror. Mobile's
routing is already correct and deliberately narrower than upstream's — a
.docx resolves to UploadRoute.TEXT — so the gap was registry hygiene, not
routing. Register the two mirrors this branch introduces at the same time.

check-mirrors.py could not anchor a function declaration, which is what
normalizeServerName is; teach extract_block to count from the body brace
rather than balancing on the parameter list.
SYNC-06: PATCH /api/share/:shareId now carries the SHARED_LINKS CREATE
permission — updating a link re-publishes conversation content, so revoking
CREATE has to stop updates too. DELETE stays ungated. Mobile called it
unconditionally, so a role with USE but not CREATE now gets 403 where it
previously succeeded. Gate the affordance and give the 403 its own message:
it is permanent for that role, and the row's delete still works.

The route also stopped minting a new shareId, so the link is stable across
a re-publish. That makes toggleShareVisibility doubly wrong as a name — it
was never a visibility toggle and is no longer a refresh — so rename it to
updateShareLink through every layer, swap the visibility-toggle icon for an
update action, and add the confirmation upstream added: the URL is already
in other people's hands and what changes is what sits behind it.
SYNC-11: isShared on conversations. Derived per list request and never
persisted, so absence means unknown rather than not-shared.

SYNC-16: adminPanelURL, langfuseFanoutEnabled and langfuseConnectionAccess
on StartupConfig. adminPanelURL is admin-gated, so absence is the normal
case and it must never be cached across accounts.

SYNC-17: isEditable on agents, applied fail-closed — it only narrows the
existing per-agent EDIT probe, never grants on absence, so an older server
that sends nothing keeps the probe's verdict. Also documents that
owner_contact no longer carries email (security advisory) and that the
mailto affordance on the owner fallback is consequently dead.

SYNC-18: flowId, oauthTimeout, failureReason, missingUserVars and
authorizationState on the MCP reinitialize response, with the wire values
of the two new enums named.

ADD-07: an MCP server whose OAuth endpoints change now 400s with
OAUTH_SECRET_REENTRY_REQUIRED until the secret is re-entered — retrying the
same body never succeeds, so it has to prompt rather than report a failure.
Both save paths handle it; the fix works on one screen otherwise.
ADD-01, verified against packages/data-provider/src/actions.ts at
db431210733e. Two findings correct the raw claim:

1. An explicit default port validates fine. validateActionDomain's new port
   check compares getExplicitPort(clientDomain) against
   specUrl.port || protocol default, and mobile posts servers[0].url
   verbatim — so both sides derive the same port and can never disagree.
   The KDoc now records why that verbatim copy is load-bearing.

2. The specific 'Port mismatch:' / 'Domain mismatch:' text never reaches a
   client: the actions route logs domainValidation.message and returns a
   fixed generic sentence. The failure path already surfaces the server's
   message, so there is nothing to unwrap.

The real defect is the Swagger 2.0 fallback. validateAndParseOpenAPISpec
rejects any spec without a servers array before validateActionDomain runs,
so a synthesized host+basePath domain is never compared against anything —
the user fills in the whole action editor and is then told the spec has no
'servers'. Say so while the spec is still on screen.
SYNC-23: the client had no ErrorTypes mapping at all, so a typed error
reached the user as its raw JSON payload — including the new
resource_recovery_required, whose whole point is telling them to reattach
files the run could not restore. Introduce StreamErrorType, map the codes
with something actionable to say, and localize them at both platform error
surfaces. Everything unrecognized keeps the server's own text, so a newer
server's code degrades instead of surfacing as a bare identifier.

MODEL_NOT_FOUND is not one of upstream's ErrorTypes: it arrives as a
LangChain documentation URL inside provider prose and is matched by regex,
so it is checked before the JSON parse.

SYNC-30 is verified-unreachable rather than ported, and ModelSelectionDelegate
now records why: an unresolvable stored agent is already INVALID here and
the tier ladder re-arms, and no bare spec-name selection is ever persisted.
Pins the submodule to db431210733e (origin/dev, 2026-08-12), moves
backendTargetVersion and UPSTREAM_VERSION onto it, and regenerates
BackendCommitMap so date gates can classify servers in the new window.

DISCOVERY.md records the range's wire contracts, including the four that
are invisible from the client without them written down: the abort route's
"new" placeholder gate, the reconcile frame's message being the only
signal separating it from a real error, activity_label_type's absence
meaning per-batch, and questions[] alone selecting the resume channel. It
also records the verified non-changes so they are not re-discovered.

VERSION_GATES.md records that this sync adds ZERO gates, and why each item
does not meet the bar — an absence that reads as an omission otherwise.
Backticked declaration names may not contain a comma on Kotlin/Native, so :core:common:compileTestKotlinIosSimulatorArm64 failed while the JVM accepted the same name.
…hable

The MCP write routes put their code under `error`, not `code`, and the wire
value carries an `MCP_` prefix the TypeScript member name drops — so the
client-secret re-entry branch could never be taken and every such refusal
surfaced as the generic save failure.

The `generation-reconcile-message` mirror watched an arrow assigned to a
const, whose parameter list balances before its body does: the extracted
"block" was the declaration line alone, so the region reported unchanged no
matter what upstream did to it. `extract_block` now counts from a
function-valued initializer's body, and `--self-test` pins that every watched
declaration form extracts more than its signature.

Also: re-resolve an agent's MCP server names when the server list is the
fetch that lands second, decode `authorizationState` / `oauthTimeout` on the
connection-status response, and record the synced commit's own date.
…rrors

The preliminary-parent 409 retry could never fire. Its predicate asked for a
409 with no code, read through `ServerErrorCode.from`, which falls back to the
`error` key for the MCP controller's convention — and that body carries its
English sentence there. `from` handed the sentence back as a code, so the
uncoded case was unreachable and the send surfaced as a failure the user had
to act on. `generationCodeOf` reads `code` alone; every predicate on the
generation routes now goes through one accessor built on it, so none of them
can pick the fallback up again. Pinned at the repository, where the broken
version is the one that retries zero times.

`ChatUiState.error` carries typed `stream_error:<wire>` markers, and only the
two snackbars resolved them. Both model-selector banners rendered the value
verbatim, and the error is cleared only when the Long snackbar returns — so
opening the selector in that window painted the bare identifier, on exactly
the codes (`missing_model`, `models_not_loaded`, …) that send a user there.

Register the two constants this branch hand-copied: the `ask_user_question`
batch limits and the MODEL_NOT_FOUND URL pattern. Both are module-private
upstream and served nowhere, so `check-mirrors.py` would have reported clean
while either drifted — and a raised MAX_QUESTIONS is a permanently paused run,
not a cosmetic gap. MAX_ANSWER_LENGTH was declared but unused; the answer
boxes now budget against it so an answer the route would reject cannot be
composed.
Four approved items only worked on the path nobody uses, and one Phase 0 claim
was wrong.

The batched `ask_user_question` fix stopped at the pause card. The composer —
the input the user actually reaches for, since the card sits at the tail of the
thread — still submitted a bare `answer` for a batch, which the resume route
rejects outright: it picks the body it accepts off the PAYLOAD, so a pause
carrying `questions` 400s and the run stays paused with Stop as the only way
out. A batch of one is now sent through the batched channel keyed by its id;
a real multi-question batch no longer claims the send at all, because one field
cannot cover ids it never showed and the card is the only input that can.

Its settled half rendered as raw JSON. A resolved batch stamps `{questions:[…]}`
as the call's args and `{"answers":{…}}` as its output, and neither is visible
to the single-question parse — so the durable record showed no question and the
answers map where the answer belongs, on every reload. It now renders one
question/answer row per id, each answer read back against its own options.

The MCP secret re-entry prompt could never appear: the refusal comes from the
update route alone, and both save paths POSTed a create even in edit mode —
which also asked the server to add a second server under a name it already
holds. Edit-mode saves now PATCH the stored server.

`isEditable` was consumed where the server never sends it. Upstream stamps it in
`getListAgents` only, so the detail screen's `canEdit && isEditable != false`
reduced to `canEdit` on every server. The list read now records the verdict
(account-keyed, dropped on any agent mutation) and the detail screen narrows
against that.

Also: restore the import order this branch broke in ChatScreenEffects, and
record that Phase 0's one DRIFT report (`memory-storage-error-types`, a
file-mode mirror) was unrelated churn — `registerMemoryTools` gained a
`toolNames` field while both guarded literals stayed byte-identical. The
pre-sync registry held 12 entries, not 11.
SYNC A2 audit: upstream 5e464bc9 (v0.8.8-rc1) turned multer file-filter
rejections from a bare 500 into 400/415 responses carrying a {message}
body ("Unsupported file type: <mime>", "No file provided"). Audited every
mobile upload surface for the generic-copy failure the item anticipated,
and found the pipeline already complete end-to-end:

- LibreChatHttpClient.extractErrorMessage lifts {message} into
  ApiException(serverAuthored = true);
- toSafeError prefers that text after the looksLikeUserMessage screen;
- FilesViewModel, FileAttachmentDelegate (Android chat attach),
  IosFileHandler (iOS chat attach) and the conversation-import path all
  render Result.Error.message, falling back to generic copy only when the
  server sent none.

No production change needed. The tests pin the seam the whole contract
rides on: a server-authored 415/400 reason reaches the message verbatim,
and the safety screen still swallows non-prose bodies.
SYNC A15: upstream da390fa9 fixed a server bug where an agent update
matching the newest version entry returned a stale 200, which web then
wrote into its query cache — the "Save reverted" report. Audited mobile's
save flow for the same exposure:

- AgentSaveDelegate.save() consumes only result.data.id (SaveSuccess
  event); the response body never reaches editor state.
- AgentRepositoryImpl.updateAgent() invalidates the account-keyed list
  cache and bumps `revision` instead of caching the returned agent, so
  every consumer refetches; reopening the editor goes through
  getAgentForEditing (a fresh GET).
- revertToVersion() does apply its response via applyAgentData, but that
  is the revert route, which the upstream bug does not touch.

Mobile is clean — no behavioral change; a comment now pins the
invalidate-not-cache invariant at the seam.
…sync

The regenerated BackendCommitMap had lost every tag row older than
0.8.7-rc1: the submodule checkout only carried a partial tag set, so the
generator saw 3 tags where the map (and BackendCommitMapTest's immutable
anchors) expect the full history. Fetched the upstream v* tags and
regenerated — 79 tag rows, dev window unchanged.

Also: FilesViewModel's new ConfigRepository dependency registered in the
Koin verify test, and the pre-HITL5 routing assertion updated to the new
contract (a multi-question batch now claims the composer's send).
garfiec added 14 commits August 18, 2026 20:16
Per-target detekt tasks (detektAndroidDebug, detektIosSimulatorArm64Main) pick
up KSP output such as Room *_Impl classes and fail on machine-written style.
Exclude by file path: string patterns match relative to each source root, and
the codegen roots live under build/generated themselves.
…enerate

Two review fixes:

- BackendCommitMap: the A12 regeneration ran against a shallow upstream
  clone, so rev-list silently returned 605 commits instead of the
  1000-commit dev window and ~392 dev rows (2026-03-21..2026-05-31) were
  dropped — servers built from those commits resolved to an unknown
  backend. Unshallowed the submodule and regenerated: 1072 rows
  (79 tag + 993 dev). The generator now fails loudly when the submodule
  is shallow or rev-list comes up short of devCommitCount.

- Regenerate (and edit-assistant, which replays the same parent user
  turn) now carries the original user message's persisted quotes into
  ChatRequest.quotes, mirroring web's overrideQuotes: the server
  rebuilds the user message from req.body.quotes on a regenerate, so
  omitting them silently dropped the quoted context while the quote
  chips stayed visible. Continue still sends none, matching web.
Two review fixes:

- The server's own SSE created frame (which carries no generationCreatedAt)
  follows the start POST's synthetic Created on every fresh send, and
  onGenerationEpoch let it null the recorded epoch — so a pause on a live
  run resumed unfenced, defeating the 409 RUN_REPLACED check on its primary
  path. onGenerationEpoch now ignores null reports; clear()/expireNow()
  remain the resets. Covered at composition level by replaying the real
  event order through StreamingManagerDelegate with a real
  PendingActionDelegate (verified to fail without the guard), plus a
  direct delegate test.

- Quotes docs (ChatRequest KDoc, VERSION_GATES, DISCOVERY) still said
  regenerate/continue/edit send no quotes; regenerate and edit-assistant
  now replay the parent user message's persisted quotes (overrideQuotes
  parity). Docs updated to the shipped wire behavior.
Derive the picker MIME types from a combine of the loaded file config and
configRepository.detectedBackendVersion instead of a one-shot read at
config-load time, so a detection that resolves late still surfaces the
version-gated .potx entry (mirrors the chat side's reactive gates).

Also record in DISCOVERY.md that iOS quote capture is deliberately
deferred: chips render on iOS but the capture affordance is Android-only
pending a CMP iOS text-context-menu investigation.
The composer's send during a multi-question ask_user_question pause recorded
its answer in a map private to PendingActionDelegate, while the card rendered
and submitted from its own remembered state. Nothing was routed wrong — the
answer simply landed where nothing could see it: the first question's field
stayed empty, the card's Send stayed disabled because that field was blank,
and no resume ever went up. The invisible words then had to be handed back
somewhere, so every swallowed send reappeared in the composer once the batch
finally resolved from the card.

The per-question drafts are now one hoisted map (MessagesState.askAnswerDrafts)
that both inputs read and write, so a composer send fills the first question the
card still shows as blank and the batch submits when the last one is in. The
send is refused (and the composer keeps its text) when there is nothing left to
answer, and the drafts are dropped rather than re-homed when the pause dies.
…lbar

The quote affordance was published by wrapping LocalTextContextMenuToolbarProvider
above the thread, and that local is null there: every SelectionContainer installs
the platform toolbar provider inside itself (CommonContextMenuArea ->
ProvideDefaultPlatformTextContextMenuProviders), so the wrapper read null, stood
down, and left the stock Copy / Select all toolbar untouched on every message.
Providing one there would not have helped either — the per-container install
would sit below it.

Foundation builds a menu's data by walking the toolbar handler's ANCESTORS
(collectTextContextMenuData -> traverseAncestors), so the item is now contributed
by a modifier above the message list instead, leaving the platform's own toolbar
and its text-classification items alone. The appended item still needs the
selected text, which only the built-in Copy item's onClick exposes; a filter
captures that item (the builder cannot read what has already been collected) and
drops the entry from any menu that has no Copy, so a text field's paste-only menu
cannot quote a stale clipboard.

The instrumented suite now drives the item through the real selection stack with
the modifier placed as production places it.
A model-not-found failure on rc1 arrives as an ERROR content part: the assistant
message persists with error:false and no text, and the raw provider JSON plus the
LangChain troubleshooting URL exist only in that part. StreamErrorType.parse
already matched it, but it was only ever called on the stream-end path, so the
same failure produced actionable localized copy on the snackbar and raw provider
JSON in the thread — and on a reopened conversation, where the run's end reason
is gone, only the raw payload was left.

Both sites now go through one entry point, StreamErrorType.markerOrText, so no
second regex home can appear beside the first. Unknown codes still render the
server's own text, which is the classifier's existing contract.
Android's DocumentsProvider reports .sh as text/x-sh, a spelling that appears
nowhere upstream — not in mimeTypeAliases, not in fullMimeTypesList, not in the
accept regexes — so rc1 answers the upload 415 "Unsupported file type:
text/x-sh". Upstream's alias table targets freedesktop and libmagic because a
browser is its only client, so there is nothing there to mirror.

Add a separate PLATFORM_MIME_NORMALIZATION map, deliberately outside the
mirrored MIME_TYPE_ALIASES and outside scripts/mirrors.json: a row added to the
mirror would make every check-mirrors.py run report drift against a table
upstream will never contain. It runs ahead of the mirrored aliases in the router
and again in FilesApi, the one place the multipart part's Content-Type is
written — the router deciding against one string while the wire carries another
is what left this reachable.

No version gate, unlike the shellscript aliases: the target application/x-sh is
in upstream's fullMimeTypesList at v0.8.6, v0.8.7 and at the shellscript-alias
commit's parent, so every supported server accepts it. The shellscript rows need
their gate because their targets only became aliases at rc1.
…re-link copy

Six defects found reviewing the v0.8.8-rc1 sync branch.

Staged quote chips were consumed when the send spec was minted but never
restored: an early abort or a pre-`created` stream error handed back only the
text, so the retry went up without the excerpts. The restore now carries them.

A failed batched ask_user_question submit re-homed the joined answers into the
composer even though the card still shows every draft, duplicating text the
user can see and arming a composer send that answerNextBatchQuestion can only
refuse. Batches now restore nothing; their words live on the card.

The pause expiry compared the server's expiresAt against the device clock, so a
skewed clock dismissed live cards on sight. It now measures the wait from the
server's own two timestamps, erring late — a stale resume 409s into the same
copy.

The Add to chat capture staged whatever the clipboard held when its poll timed
out, quoting the user's previous clip. Capture is now identified by the clip's
write timestamp and stages nothing when the copy never lands.

Shared-link update promised the URL survives, which only holds on rc1+; earlier
servers mint a new shareId. The confirmation copy is version-gated, fail-safe to
the warning. The row is also patched rather than rebuilt from a response that
carries no title, which relabelled every updated link "Untitled Conversation".

The comparison secondary lane rendered an unanswered ask as a record card with
an empty answer in a pane that hosts no card to answer it.

Also registers the ErrorTypes and MCPErrorCodes mirrors in scripts/mirrors.json,
which the repo requires in the same PR that hand-copies them.
Version gating answered in booleans, which forced two unlike servers into
one `false`: a build whose tag proves it predates a feature, and one that
could not be placed at all. The second population is not old — it is dev
builds reporting the previous release (upstream bumps package.json at rc
prep) and servers built past this app's commit-map pin, i.e. the servers
most likely to HAVE the feature.

The rc1 sync dropped the landedDate fallbacks on four gates, which handed
that conflation real consequences. The worst is the queued-attachment TTL
touch: a 0.8.8-cycle dev build reporting 0.8.7 no longer gets the hold, so
the upload-window reaper can collect an attachment out from under a queued
message and the send then references a file the server deleted.

BackendVersion.featureSupport returns PRESENT / ABSENT / UNKNOWN, with
supportsFeature now its `== PRESENT` shorthand, so every gate not touched
here keeps its behaviour byte for byte. ABSENT is only ever a tagged build
below the threshold or a dev build a landedDate settles; a dev build's
reported version is a floor and never a ceiling, so nothing else is proof.

The two gates whose features are discoverable moved to probe-and-latch:
suppress on ABSENT, let UNKNOWN through to exactly one real call, latch its
404 for the rest of the server session. Restoring the dropped dates was the
other candidate and rots by construction — day granularity cannot separate
same-day commits, and the pin's coverage window re-hides the feature days
after each sync. A 404 is exact and does not age.

The latch needs a field of its own: isSupported also reads false before the
first probe, so reusing it re-probes on every picker open. It is reset on
account switch, since these repositories are app-wide singletons and the
verdict describes one server — FileRepository gains clear() alongside the
tool-favorites one NavHostViewModel already calls. The renewal heartbeat
re-asks per tick rather than only at start, because this is the one gate
whose answer legitimately flips, once, when the probe lands.

Steering and the context-projection suppression stay two-state on purpose:
one has nothing to probe and costs nothing closed, the other's ungated
branch already issues a call a 404 makes free.
…erver

The unit tests pin the decision table but cannot show that a call is really
issued or really withheld. GateProbeDeviceTest drives both probe-and-latch
gates over real HTTP, on a device, once per server identity.

Two harness defects had to be fixed before it proved anything, and both are
the reason the test is worth keeping. It first passed while its
POST /api/files/usage never left the device: the test client lacked the
production contentType default, so the request died inside ContentNegotiation
and a counter watching onRequest recorded the intention as an event. It now
counts responses. It then could not latch, because without the production
HttpResponseValidator a 404 arrives as an ordinary response and the repository
reads it as a successful touch.

The rig runs the real image behind a small logging proxy: the log makes "no
call was made" an observation rather than an inference, and a 404 mode on just
the two gated routes stands in for a server that predates them. Server
identity comes from the BUILD_COMMIT env var upstream already prefers over
git rev-parse, so one server impersonates every population the gate must tell
apart.

Measured on a Pixel 10 Pro Fold AVD against v0.8.8-rc1: the rc1 tag resolves
RC/0.8.8-rc1, the v0.8.7 tag resolves OFFICIAL/0.8.7, and an unmapped commit
resolves to nothing. A proven-old identity issues no request at all; an
unplaceable one issues exactly one and keeps the feature; a 404 latches it off
for the rest of the session.

The class self-skips without the rig and detects which of the two rigs is up,
so neither half fails for want of a fixture.
A pause does not end the run, so isStreaming stays true across it and the
per-frame follower keeps pinning the list's tail while the output it exists
to follow has stopped. The pause card is what grows underneath — a long
question, expanding options, an answer field taking a second line — so the
follower chases it and walks the question off screen while it is being read.

The one-shot scroll that brings a new card into view stays; it now flags
itself as programmatic, since animating up to the tail otherwise reads to the
scroll-away detector as the user dragging away from the bottom.
Comments describing bugs that only ever existed inside this branch read, on
develop, as a regression trail that never happened; they are rewritten as
present-tense invariants or dropped. Six explanations repeated across five to
seven files are collapsed to one canonical home each, with the copies pointing
at it. Wire-contract, data-loss and concurrency invariants are left alone.
…to-view

The keyboard handler exists for the composer, which sits outside the list: the
list learns about the IME from nothing but its own shrinking viewport, so it
jumps to the tail of the last item. A field INSIDE the list needs no such help
— foundation scrolls a newly focused node into view and knows where the focus
is — but both running means the jump wins and scrolls the user off the very
field they tapped, which a tall pause card makes obvious because its fields sit
anywhere in it. The handler now stands down whenever the list holds focus.
@github-actions

Copy link
Copy Markdown
Contributor

Android debug APK

Artifact: switchboard-android-debug-365
Download: switchboard-android-debug-365.zip
Retention: 90 days
Commit: 0253976d8210946986dd17d8898dc3bbd1323f3d

Download requires a GitHub login. Installs over previous debug builds without uninstalling (stable signing key).

@garfiec
garfiec merged commit f9de8b7 into develop Aug 19, 2026
6 checks passed
@garfiec
garfiec deleted the chore/sync-upstream-v0.8.8-rc1 branch August 19, 2026 01:42
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