Repository navigation
Description
Problem
#750 made the client side treat pending_approval as a waiting hold: the card draws, Approve appears, and the overlay lists it. But the host side that answers the click still accepts only pending.
So a hold whose status is literally pending_approval now renders an Affirm/Reject pair that the host will refuse: the person clicks Approve on a card the product itself decided to show, and nothing happens.
This was left as a non-blocking note on #750 (Reviewer's comment) because nothing in the tree writes that status from the client side today. It becomes a live defect the moment a host or plugin answers pending_approval, and the card will have promised the opposite.
Reproduce
- Put a held reply in the client with
status: 'pending_approval'(the shape#750added coverage for). - Confirm the card draws with
Approve/Reject— it does, by design after#750. - Confirm the host's
decide()path rejects a status other thanpending, so the click cannot resolve the hold.
What changed (expected)
- The host's
decide()uses the same shared judgment the client now uses (isApprovalWaiting, or its host-side equivalent) instead of comparing to the stringpending. - The statuses the host accepts are derived from that one place, so the client and the host cannot disagree about what
Approvemay answer.
Acceptance
- A hold with
pending_approvalcan be approved and rejected through the host path;approved,autoand a missing status are still refused, as today. - One test asserts the refused statuses are refused and the waiting ones are not, so a third waiting spelling cannot be added on one side only.
- Red evidence: put the host comparison back to
=== 'pending'and thepending_approvalcase must fail.
Out of scope
Which statuses count as waiting (that lives in approval-waiting.js, from #750), the drawing of the card, and the host's storage format for holds.
Scope widened after triage
The first version of this issue named only the host's decide(). Triaging it against the tree found three more places that read the same status, and they make the defect much wider than one button:
core/src/host/hooks.js:566— the refusal that starts this issue.core/src/host/hooks.js:499(heldOf()) — folds every status that is not sent/pending/failedintorejected, so the chat card still reads "Not sent" for the same hold:isApprovalWaiting('rejected')is false. Fixing the dock while leaving this alone gives two answers on one screen.core/src/host/hooks.js:413— treats any non-pendingstatus as finished, so a waiting hold can be cleaned up mid-wait.core/src/host/hooks.js:397—PENDING_MAXcounts onlypending, so waiting holds do not count against the cap.
Where the judgment must live
Move the shared judgment to core/src/shared/approval-waiting.js (the precedent is core/src/shared/mentions.js, already imported by both host and client). The host cannot import from core/src/client/, so leaving it there forces a second copy — and a second copy is exactly what this issue exists to prevent.
Consequences to carry through: rebuild core/client.js and regenerate patches/ds-bot/ (#750 already mirrors that file into the patch directory).
Acceptance (updated)
- Host and client import the same module; the acceptance is that neither side holds its own literal, not that two copies agree.
heldOf()keeps apending_approvalhold as waiting, so the card and the dock say the same thing.- The cleanup path (
:413) and the cap (:397) count waiting holds as waiting. - Red evidence, one per line changed: revert
:566→ the approve case fails; revert:499→ the card case fails; revert:413→ the cleanup case fails.
Activity
Birfy commentedon Oct 22, 2026
Triaged
bug+area:automation+agent-ready(Triage, 2026-10-22). Scope written down here so the fix can be small and still complete.Premise, checked on main
1bda9b2a(after#750)The refusal is one line:
core/src/host/hooks.js:566—if (approval.status !== 'pending') throw … 'is already ${approval.status}'. Retry keeps its own guard at:557-565(failedonly); leave that alone.The button really is reachable: the dock asks the host for
[...WAITING_STATUSES, 'failed'](core/src/client/flybot-slots.js:207) and the host'slist()filters by whatever is stored (hooks.js:511-521), so a storedpending_approvalrecord is listed and drawn with Approve / Reject whiledecide()rejects the click. Confirmed as described.Honesty about reproduction: nothing on the host writes that spelling today —
hold()writespending(:402-411). The record comes from a plugin-side hold (Bot Social's outbound hold usespending_approval) or from state written by an earlier build. So the red repro is a unit one: seedstate.flybot.approvals[id]withstatus: 'pending_approval'and call thedecideendpoint.Two sites the body does not name, same defect
#750.heldOf()folds every status that is not sent/pending/failedintorejected—hooks.js:499— before the client sees it.isApprovalWaiting('rejected')is false, so the bubble for that same hold renders "Not sent" (held-reply.js:53) while the dock shows Approve. Passing the waiting status through:499is part of this fix, otherwise one hold is decided by two different answers on one screen.:413treats everything that is notpendingas finished and deletes it once the store passesAPPROVALS_KEPT;:397only countspendingtowardPENDING_MAX. Both are one-line uses of the same predicate. Without them a waiting hold can vanish (no card, no decision) or let the cap overflow — neither is visible to a UI test.invalidateFor(:364) also testspending; a waiting hold would survive its Bot's deletion as a card that answers "the sender Bot no longer exists" when clicked. Include it if cheap (one line) — and if not, say so in## Not run/ out of scope rather than leaving it unnamed.Where the rule lives
#750put it incore/src/client/approval-waiting.js, which the host cannot import — so the host would need a second copy, and two copies is exactly what this issue is about. The repository already has the right place:core/src/shared/(shared/mentions.jsis imported bycore/src/host/hooks.jsandcore/src/client/text.js). Move the rule tocore/src/shared/approval-waiting.js, import it from the host and from the three client sites, and re-exporting from the old path is not enough for "the client and the host cannot disagree".Acceptance (write these before the code)
pending_approvalhold is approved and rejected throughdecide(); the record endsapproved/rejected,decidedAtset, andapproverunsrelease()exactly aspendingdoes.code: 'bot/stale':approved,auto,rejected,stale,denied,failed(withoutretry), and a record with nostatus.heldRepliesOfreports the waiting spelling, notrejected; the card asks the shared rule (one test that would pass with a third spelling added on one side only is not enough — assert the client and the host import the same module, e.g. the host-side test imports the rule and the client-side test fails if either site keeps a local literal).PENDING_MAXcount includes waiting holds.:566back to!== 'pending'→pending_approvaldecide case;:499back to the fold → the waiting chat-card case;:413back to!== 'pending'→ the prune case.Build touch points
core/client.jsmust be rebuilt (build-client --checkexit 0) andpatches/ds-botregenerated (node scripts/ds-bot-patches.mjs generate, thencheckexit 0) —#750mirroredapproval-waiting.jsintopatches/ds-bot/files/src/client/, so a move has to move that file too. Changelog fragment underchangelog.d/.Out of scope
Any change to what gets stored when a send is held (the host keeps writing
pending), the card's layout, and who may create apending_approvalhold at all.Birfy commentedon Oct 22, 2026
Written-by: Dev4
Claimed. Branch:
fix/754-host-waiting-hold(frommain); the PR will carryFixes #754.Plan, following the widened acceptance:
core/src/shared/approval-waiting.jsand have both host and client import that one module (theshared/mentions.jsprecedent), so neither side keeps a literal.core/src/host/hooks.js:decide()(:566) accepts a waiting status through the shared predicate;heldOf()(:499) keeps apending_approvalhold waiting instead of folding it intorejected; the cleanup path (:413) andPENDING_MAX(:397) count waiting holds as waiting.pending_approval, the refused statuses (approved,auto, missing) staying refused,heldOfrendering the hold as waiting, cleanup leaving it, and the cap counting it; one assertion that host and client import the same module and hold no literal of their own.decide()predicate → approve case; revertheldOf()→ card case; revert the cleanup check → cleanup case.core/client.js, regeneratepatches/ds-bot/, docs + one changelog fragment.Read one waiting-hold rule on the host too (#754)
Read one waiting-hold rule on the host too (#754)
ThinkFlowLab#754: one waiting-hold rule for the host and the client (T…
Read a gone friend's Bot as one sentence and drop the useless Retry (T…