Skip to content

feat(delivery): multi-message replies over ACP - #1509

Open
IveTian wants to merge 3 commits into
openabdev:mainfrom
zeabur:main
Open

feat(delivery): multi-message replies over ACP#1509
IveTian wants to merge 3 commits into
openabdev:mainfrom
zeabur:main

Conversation

@IveTian

@IveTian IveTian commented Aug 26, 2026

Copy link
Copy Markdown

What problem does this solve?

An agent turn arrives as one wall of text. AdapterRouter::stream_prompt_blocks concatenates every agent_message_chunk into text_buf and splits it only at the platform's length limit (format::split_message). That split is a transport concern: it carries no information about where one thought ends and the next begins, so an agent cannot say "on it" and then, a beat later, "your flight moved to 8pm". The second form is all OpenAB can produce today.

A proactive agent has the mirror problem: every unsolicited event (a mail, a webhook retry, a 3am alert) costs an LLM call just to decide whether to stay quiet, and a stateless turn cannot tell a webhook retry from a fresh event.

This PR adds four opt-in pieces. An unconfigured deployment behaves byte-for-byte as before.

Piece Switch What it does
Structured delivery [delivery] mode = "structured" The agent returns an openab.turn.v1 envelope; the broker sends one platform message per bubble
Turn envelope turn_envelope (openab-agent) Replies go through a reply tool whose input schema is the envelope, so the provider API guarantees valid JSON
Event triage [triage] Dedupe / quiet hours / cooldown / daily cap, for unsolicited events only
Sequential delivery [delivery] mode = "sequential" Each bubble delivered the moment the agent decides it, via an openab_message ACP extension

A newline is deliberately not a message boundary — an address or a code block stays in one message. Splitting is by envelope entry only.

Design: docs/adr/structured-delivery.md

Closes #

Discord Discussion URL:

Review Contract

Goal

Let an agent reply with deliberate conversational beats ("on it" → the answer) instead of a single block, and let a proactive agent decide not to speak without burning an LLM call on every routine notification — while guaranteeing that the machinery (the JSON envelope) is never visible to a user, under any failure.

Non-goals

  • Tool authorization / policy engine. The model may only propose; next.type = tool is recorded and otherwise ignored. Real authorization belongs to the agent runtime and to openab-mcp's existing tool_filter.
  • Long-term memory, recipes. openab-agent already carries the recipe mechanism (skills.rs / SKILL.md) and persona override (AGENTS.md); neither needed new code here.
  • A durable delivery ledger. Bubbles are sent sequentially inside one turn and pool::with_connection holds the per-thread mutex throughout, so two turns cannot interleave and there is no retry path to make idempotent.
  • A second event protocol. Proactive events reuse openab.gateway.event.v1 with one additive proactive flag, inheriting the trust gate, batching and session routing rather than re-implementing them.
  • Persisted triage counters. Deliberately in-memory (see Residual Risks).
  • Heuristic chunking of free text (paragraph/sentence splitting à la OpenClaw). Boundaries here are semantic and model-declared, never guessed from punctuation.
  • Apple Messages ingress. Unrelated; see docs/adr/imessage-integration.md.

Accepted Residual Risks

  1. Structured mode gives up token streaming, on every platform including Slack's native assistant stream. Nothing may reach the user before the envelope validates. Mitigation: typing/status indicators still work; the mode is global and opt-in, so long-form deployments simply stay on text.
  2. Triage counters are in-memory. A restart forgets the cooldown and the day's count. Accepted because the failure direction is safe: a restart can allow one extra proactive message, never silence a needed one. Recovery: none required. Durable counters belong to the agent runtime, which owns user-facing state anyway.
  3. Config mismatch between broker and agent can be user-visible. Two combinations produce raw JSON or no reply at all. Mitigation: the full pairing table is in docs/native-agent.md with the bad rows marked "do not ship"; a schema mismatch is logged at agent startup. Not defended in code — the broker cannot see the agent's config.
  4. Sequential mode costs one model call per bubble. Mitigation: structured remains the recommended default and this is documented as the experiment to run against it, not a replacement.
  5. [reactions] tool_display and narration_display are ignored in both bubble modes. There is no single message to prefix with a tool summary, and an extra ✅ 2 tool(s) bubble would break the reply's rhythm. Documented.
  6. The branch point itself has no automated test. stream_prompt_blocks runs inside a with_connection closure and needs a live ACP session. Mitigation: every decision it makes is extracted into a pure function with direct tests (requires_send_ack, sequential_outcome, the same pattern finalize_body / split_delivery already follow), and scripts/bubble-test/ exercises the wired path end to end offline.
  7. Third-party ACP agents must be prompted to emit the envelope. Only openab-agent enforces it via tool schema. A model that drifts turns every reply into one fallback message. Mitigation: on_parse_error = "fallback_text" (default) still delivers the prose, with any envelope fragment stripped.

Acceptance Criteria

Backward compatibility

  • No [delivery] / [triage] section → behaviour identical to before; all pre-existing tests pass untouched (scripts/bubble-test/text.toml is the regression baseline)
  • An event without the proactive field parses and is dispatched unconditionally

Bubble semantics

  • alpha\nbeta\ngamma in one bubble → exactly one platform message
  • Three envelope entries → three messages, stable order, bubble_delay_ms apart
  • An over-long bubble hard-splits in place without reordering the next bubble
  • Over max_bubbles or over max_bubble_chars → the whole plan is rejected, never silently truncated
  • next: silent sends nothing, and wins over a non-empty messages

The envelope is never visible

  • Raw JSON never delivered under any on_parse_error policy
  • A truncated envelope never delivered (classified as "found", so fallback strips it)
  • Prose containing an unrelated JSON snippet (even one with a "schema" key) is not mangled by the stripper (F1 regression test)
  • Error Display carries no bubble text

Triage

  • A message a human sent is never suppressed, under any triage config
  • A webhook retry (same event_id) produces no second message
  • A suppressed event neither consumes the daily cap nor restarts the cooldown
  • Quiet hours and the daily rollover honour the configured timezone
  • Every suppression logs a stable reason tag (duplicate / quiet_hours / cooldown / daily_cap)

Sequential

  • reply is non-terminal: the model is consulted again after the first bubble
  • The bubble cap is enforced per turn, not per call; reaching the cap is not reported as a failed turn (F2)
  • Per-send gateway ack applies to sequential as well as structured, so bubbles cannot be reordered by the gateway (F3)
  • A reply call cut short by the cap reports to the model what was actually delivered (F4)
  • A failed bubble abandons the rest and marks the turn failed; the user keeps what arrived
  • An agent that never emits the extension falls through to the text path

Cross-crate contract

  • openab-agent's output and the broker's parser are pinned to shared fixtures in docs/fixtures/ (turn-envelope-v1.json, sequential-message-v1.json), verified to fail on drift

Follow-ups

  • Policy engine + pending actions for tools that touch other people or external systems. next.type = tool is the seam left for it.
  • Durable triage state — an ignored-alert history and per-source mutes, in the agent runtime.
  • LINE reply batching: the Reply API accepts up to 5 messages in one free call; today only bubble 1 uses it and the rest fall through to Push quota.
  • Cost/latency comparison of sequential against structured on real traffic — the experiment this PR exists to enable.
  • A [[bubble]] text-marker probe (ADR §5.1) if the bubble experience needs A/B validation with agents that cannot be given a tool schema. DeliveryMode is an enum, so it slots in beside the envelope.
  • One intermittent test failure was observed once during a heavily loaded end-to-end run and did not reproduce across two clean re-runs; the test name was not captured. Worth watching in CI.

At a Glance

                         ACP agent (openab-agent or any ACP agent)
                                        │
        ┌───────────────────────────────┼───────────────────────────────┐
        │ mode = "text" (default)       │ mode = "structured"           │ mode = "sequential"
        │                               │                               │
        ▼                               ▼                               ▼
  agent_message_chunk*          reply({messages:[..], next})     reply(["on it"])  ──► openab_message ──► send ①
        │                               │                               │
        ▼                               ▼                        tool call (lookup)
     text_buf              {"schema":"openab.turn.v1",                  │
        │                   "messages":[{id,text},..],           reply(["found it"]) ─► openab_message ──► send ②
        ▼                   "next":{"type":"stop"}}                     │
  split_message                         │                          turn ends when the
  (platform limit only)     parse ──ok──► DeliveryPlan               model stops calling tools
        │                     │            │
        ▼                     │      send bubble_1 ─delay─► bubble_2 ─delay─► …   (never a newline split)
     1 message                │            (per-bubble ack on gateway)
                              └─err──► strip_envelope ──► on_parse_error
                                                          fallback_text | error_message | silent
                                                          (raw / truncated JSON never leaves)

  Proactive ingress (openab.gateway.event.v1 + proactive: true):

  event ─► should_skip_event ─► trust gate ─► [triage] ─► dispatcher ─► agent
                                 (L2/L3)     dedupe · quiet_hours · cooldown · daily_cap
                                             suppressed → log reason=…, no LLM call
                                             human message → never suppressed

Prior Art & Industry Research

OpenClaw:

OpenClaw's answer to "several bubbles per turn" is block streaming — a config-driven chunker over the model's free-text output, not a model-declared boundary. blockStreamingDefault turns it on; blockStreamingBreak chooses "text_end" (flush as the chunker emits) or "message_end"; blockStreamingChunk sets minChars / maxChars / breakPreference (paragraph → newline → sentence → whitespace → hard break); blockStreamingCoalesce (idleMs, minChars, maxChars) merges small chunks to avoid single-line spam; humanDelay.mode: "natural" adds an 800–2500 ms randomized pause between blocks after the first. Code fences are closed and reopened across chunks to keep Markdown valid. The model has no way to control boundaries — there is no marker or tool. The failure mode of boundary-by-heuristic is visible in openclaw#31679: a short reply arrived as "Per" and "feito, @jarbas! 👊" in two Discord messages because content blocks were dispatched independently; closed as not planned.

For proactive quiet, OpenClaw's heartbeat runs the model on a schedule and relies on a sentinel in the reply: HEARTBEAT_OK at the start or end of the output suppresses delivery (only if the remainder is under 300 chars; a mid-reply sentinel is ignored). activeHours { start, end } defers ticks outside a window; showOk / showAlerts / useIndicator gate what is shown, and an empty HEARTBEAT.md skips the run entirely. There is no dedupe of repeated events, and staying quiet costs one model call per tick unless the whole run is skipped.

Hermes Agent:

Hermes splits only at the platform's length limit, then paces the pieces: HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS (default 0.6, grace window before flushing a queued chunk) and HERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS (default 2.0, delay between split chunks). DISCORD_REPLY_TO_MODE (off / first / all) decides which chunks carry a reply reference. Streaming is edit-in-place on platforms that allow it, and tool_progress_grouping (accumulate default, or separate for one message per tool) controls tool-progress bubbles. There is no mechanism for the model to deliberately emit several distinct messages in one turn, and no documented quiet-hours or per-event dedupe; cron ticks every 60 s and delivers whatever the job produces. What Hermes does have that we deliberately do not is a durable delivery ledger (gateway.delivery_ledger, at-least-once, "♻️ Recovered reply" prefix on ambiguous redelivery) — see Non-goals for why one turn's bubbles do not need it here.

Other references (optional):

  • ACP: session/prompt yields one response and agent_message_chunk is an append-only text stream — the protocol has no in-turn message boundary, which is why the boundary has to live either in the model's output (envelope) or in a non-standard sessionUpdate (sequential).
  • This repo's existing turn-final directive syntax ([[reply_to:…]], docs/output-directives.md) and schema-id convention (openab.sender.v1, openab.gateway.reply.v1), both reused here.
  • Anthropic / OpenAI structured tool inputs: a tool's input_schema is validated by the provider, which is what lets openab-agent make the envelope a guarantee rather than a prompt request.

Proposed Solution

1. Envelope, parsed in the broker (crates/openab-core/src/structured_delivery.rs). A versioned wire type openab.turn.v1:

{ "schema": "openab.turn.v1",
  "messages": [ { "id": "bubble_1", "text": "on it" },
                { "id": "bubble_2", "text": "your flight moved to 8pm\ngate B12" } ],
  "next": { "type": "stop" } }

deny_unknown_fields on every struct; next defaults to stop; silent wins over non-empty messages at parse time so the delivery loop only ever reads bubbles; tool is recorded as a proposal and otherwise ignored. Over max_bubbles / max_bubble_chars rejects the whole plan. Pure functions, no I/O. strip_envelope removes a complete parsed envelope, or — for a truncated fragment — falls back to a loose "messages" substring match, because over-stripping half an envelope is the safe direction to be wrong in.

2. Router branch, decided at construction time. AdapterRouter::with_delivery (mirrors with_trust). Structured mode must be known before the turn starts: streaming and Slack native assistant mode are forced off, because a turn-final directive cannot stop half a JSON object from being edited into a live message (ADR §2.1 invariant I1). The branch runs before finalize_body / display_for, both of which prepend text that would otherwise make every envelope fail to parse. One ChatAdapter::send_message per bubble, bubble_delay_ms apart; GatewayAdapter gains a per-send await_ack (requires_send_ack) so a WebSocket gateway cannot reorder bubbles.

3. Producer side (openab-agent/src/turn_envelope.rs). With turn_envelope = "openab.turn.v1", the agent registers a reply tool whose input_schema is the envelope — messages: string[] (capped by maxItems) plus next: "stop" | "wait" | "silent". Schema id, bubble ids and the next object shape are filled in by code, so the model cannot get them wrong. Text outside the tool is not delivered. Voice/persona reuses AGENTS.md; recipes reuse skills.rs.

4. Event triage (crates/openab-core/src/event_triage.rs). Dedupe by event_id, quiet hours + daily rollover in a configured timezone, cooldown, daily cap. Runs after the trust gate (an unauthorized source cannot burn a conversation's allowance) and before dispatch (a suppressed event costs no LLM call). Applies only to events flagged proactive: true — quiet hours that swallow a question a human asked are an outage, not a feature. Both gateway ingress paths share one triage_gateway_event. Every suppression logs a structured line with a stable reason tag.

5. Sequential extension (mode = "sequential"). A non-standard session/update variant openab_message, sharing agent_message_chunk's content: {type, text} shape. The broker sends on arrival; openab-agent's reply tool becomes non-terminal and loses next (the turn ends when the model stops calling tools; silence is never calling reply). No capability negotiation: an agent that never emits the event falls through to the text path, and an event arriving at a broker not in sequential mode is logged loudly and ignored. On failure after the first bubble, the user keeps what arrived and the turn is marked failed.

Why this approach?

  • Boundaries are semantic, so the model declares them. OpenClaw's heuristic chunker and Hermes's length-limit split both decide boundaries without knowing where a thought ends, and #31679 shows the result. A newline in particular must not be a boundary (addresses, code blocks). Only the model knows that "on it" and "found it" are two beats — so the boundary lives in its output.
  • Schema-enforced, not prompt-requested. Making the envelope the reply tool's input_schema means the provider validates it. That removes the "model forgot the format" class of failure for openab-agent entirely; for other ACP agents the parser + fallback_text keeps it non-fatal.
  • Construction-time mode, not a per-turn directive. The only way to guarantee "nothing reaches the user before the envelope validates" is to know the mode before streaming starts. A [[delivery:…]] directive exists only as a per-turn schema override, never a switch.
  • Reject, don't truncate. A silently dropped tail reads to the user as a complete answer — the one outcome worse than a plain-text fallback.
  • Two layers of quiet. Cheap deterministic rules ([triage]) decide whether the agent is woken at all; the model's next: silent decides whether it has anything worth saying. OpenClaw's HEARTBEAT_OK is the second layer alone, which costs a model call per routine tick and cannot dedupe a webhook retry.
  • Reversible and byte-identical by default. One config value returns any deployment to today's behavior; no ChatAdapter trait change; Discord and Slack adapters untouched.

Known limitations: structured mode has no token streaming (worse for long-form answers, hence global opt-in); sequential mode costs one model call per bubble; broker/agent config must be paired by the operator and is not validated across the process boundary.

Alternatives Considered

  • Heuristic chunking of free text (OpenClaw block streaming). ~zero prompt cost and works with any agent, but it cannot tell an address from two thoughts, cannot express "stay silent", and splits mid-content when the model's blocks happen to fragment. Explicitly a non-goal.
  • A text marker ([[bubble]] on its own line), ADR §5.1. ~50 lines and composes with the existing directive syntax. Rejected as the primary mechanism: it cannot carry next (silent in particular is first-class for a proactive agent), and a marker inside a fenced code block would split a bubble mid-content. Kept as a possible cheap A/B probe — DeliveryMode is an enum.
  • ACP extension only (no envelope), ADR §5.2. True sequential generation is the only design where a later bubble can reflect a tool result the earlier one triggered — so it shipped, as sequential. But one model call per bubble is a real price and most replies do not need a mid-turn beat, so the envelope stays the recommended default rather than being replaced.
  • The agent calls the platform API directly, ADR §5.3. Already the documented pattern for attachments (docs/sendfiles.md). Rejected for text: it bypasses the trust gate, per-thread serialization, split_message, convert_tables and the reaction lifecycle, and requires handing platform credentials to the agent — which AGENTS.md rule 3 exists to prevent.
  • A dedicated proactive-event protocol, ADR §7.1. openab.gateway.event.v1 already carries everything an inbound event needs; a parallel protocol would need the trust gate, batching, session routing, STT and attachments re-implemented or skipped — and "skipped" is how an unauthenticated path into the agent gets built. One additive proactive flag instead.
  • Persisted triage counters / a delivery ledger (Hermes-style). Deferred: a restart's failure direction is one extra message, never a lost one, and bubbles inside one turn have no retry path to make idempotent.
  • Sentinel-in-reply quiet (HEARTBEAT_OK). Subsumed by next: silent, which is a typed enum the provider validates rather than a string the broker has to find at the edge of the output.

Validation

Rust changes:

  • cargo check passes, including cargo check --features unified
  • cargo test passes (including new tests) — as recorded on the merged fork PR (zeabur/openab-multiturn#1): broker cargo test --workspace --lib 1429 passed, 0 failed; openab-agent cargo test 90 passed, 0 failed. After the review-fix commit (ff551a42, findings F1–F4, each with a regression test naming the finding): broker 816 passed (openab-core), agent 92 passed.
  • cargo clippy clean on every touched file (three crates)

Docs:

  • docs/adr/structured-delivery.md, docs/config-reference.md#delivery, docs/native-agent.md#multi-message-replies-turn-envelope, docs/output-directives.md, docs/fixtures/README.md — links resolve; the pairing table names the two combinations that must not ship.

Manual testing — scripts/bubble-test/ runs every delivery path offline (fake WebSocket gateway + fake ACP agent replaying scripted turns; no LLM key, no platform account, no network). Steps: uv run scripts/fake-gateway.py in one terminal, cargo run -- run -c scripts/bubble-test/<mode>.toml in another, switch SCENARIO in the config's [agent] env. Observed:

Config / scenario Observed
structured.toml / envelope 3 separate bubbles, ~400 ms apart (bubble_delay_ms)
structured.toml / multiline 1 bubble, 3 lines — newline is not a boundary
structured.toml / silent nothing delivered
structured.toml / prose one message, verbatim — model forgot the envelope, user still gets the reply
structured.toml / broken sorry, one sec and no { reaches the gateway; log structured turn did not parse … policy=fallback_text
structured.toml / toolong the parse_error_text line — over max_bubbles, body was nothing but the envelope
sequential.toml / seqslow 3 bubbles ~2 s apart with bubble_delay_ms = 0 — the pause is the agent's, proving each bubble was sent when decided
sequential.toml / seqhalf 2 bubbles, then the turn fails; the user keeps what arrived
triage.toml the human message delivered; /proactive flight delayed suppressed with decision="suppressed" reason="quiet_hours"; repeat → cooldown; /id evt_42 twice → duplicate; 4th in a minute → daily_cap
text.toml one ordinary message — the regression baseline

IveTian and others added 3 commits August 25, 2026 17:25
Turn one agent turn into several chat messages ("bubbles") instead of one wall
of text, plus the noise control a proactive agent needs to not become a
nuisance. Four opt-in pieces; an unconfigured deployment behaves byte-for-byte
as before.

Structured delivery — `[delivery] mode = "structured"`
  The agent returns a versioned `openab.turn.v1` envelope and the broker sends
  one platform message per bubble. A newline is deliberately NOT a message
  boundary: multi-line content (an address, a code block) stays in one message.
  Branches before `finalize_body`/`display_for`, both of which prepend text
  that would otherwise make every envelope fail to parse.

Turn envelope — openab-agent
  Replies go through a `reply` tool whose input schema IS the envelope, so the
  provider API guarantees well-formed JSON rather than the prompt asking
  nicely. Schema id, bubble ids and the `next` shape are filled in by code, so
  the model cannot get them wrong. Voice reuses the existing AGENTS.md
  mechanism; recipes reuse skills.rs.

Event triage — `[triage]`
  Dedupe / quiet hours / cooldown / daily cap, evaluated before dispatch so a
  suppressed event costs no LLM call. Applies ONLY to events flagged
  `proactive`: quiet hours that swallow a question a human asked are an outage,
  not a feature. Suppressions log a stable reason tag, because "the agent chose
  to stay quiet" and "the broker never asked it" are otherwise
  indistinguishable.

Sequential delivery — `mode = "sequential"`
  A non-standard `openab_message` ACP extension delivers each bubble the moment
  the agent decides it, so a later bubble can reflect a tool result the earlier
  one triggered ("on it" → lookup → the answer). Costs one model call per
  bubble; the envelope stays the recommended default. Agents that do not
  implement it never emit the event and fall through to the text path — the
  absence of an event is the negotiation.

The envelope is never visible. Raw or truncated JSON is stripped before any
fallback reaches the user, and a truncated envelope is deliberately classified
as "found" — being cut off mid-object is when leaking is most likely.

Two cross-crate fixtures pin the producer/consumer contracts, since
openab-agent is its own workspace and cannot call the broker's parser.

Design:  docs/adr/structured-delivery.md
Verify:  scripts/bubble-test/ — runs every path offline, no LLM key, no
         platform account, no network

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nbc3CQ781crXdUJudAnyf3
All four confirmed against the source; no false positives.

F3 (yellow) — `await_ack` skipped sequential mode
  The gateway gated the per-send ack on `mode == Structured`, so sequential
  bubbles went onto the WebSocket without waiting and could be reordered by the
  gateway — the exact hazard ADR §4 documents. Sequential needs it more than
  structured: its bubbles are milliseconds apart. The rule now has a name,
  `requires_send_ack`, so it has one home instead of being an inline comparison
  that the next mode can silently miss.

F2 (yellow) — a capped turn reported as failed
  Reaching `max_bubbles` set the same flag a real send failure sets, so a turn
  that said everything it was allowed to say surfaced ❌ to the user. Cap and
  failure are now separate states, and the outcome rule is a pure helper
  (`sequential_outcome`) that can be tested without a live ACP session — the
  same reason `finalize_body` exists.

F1 (green) — envelope detection could eat a JSON sample
  `looks_like_envelope` matched substrings, so prose showing the user a config
  with a `"schema"` key was treated as a malformed envelope and stripped. A
  complete fragment is now parsed rather than guessed at, and the marker is
  `messages` rather than `schema`: an envelope is by definition a list of
  messages, while a bare `schema` key is common in unrelated JSON. A truncated
  fragment cannot be parsed and keeps the loose substring check — over-stripping
  half an envelope is the safe direction to be wrong in.

F4 (green) — truncated delivery reported as complete
  A `reply` call cut short by the cap still got "delivered", leaving the model
  believing it had said something the user never saw. The tool result now
  reports what actually went out.

Each fix carries a regression test naming the finding it closes.

broker 816 passed · agent 92 passed · clippy clean · unified builds

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nbc3CQ781crXdUJudAnyf3
feat(delivery): multi-message replies over ACP (structured / sequential / triage)
@IveTian
IveTian requested a review from thepagent as a code owner August 26, 2026 07:54
@openab-app openab-app Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Aug 26, 2026
@openab-app

openab-app Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Caution

This PR is missing a Discord Discussion URL in the body.
This PR will be automatically closed in 24 hours if the link is not added.

All PRs must reference a prior Discord discussion to ensure community alignment before implementation.

Please edit the PR description to include a link like:

Discord Discussion URL: https://discord.com/channels/...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant