Skip to content

fix(ask): show multi-select state on remote asks - #3899

Merged
Yeachan-Heo merged 3 commits into
Yeachan-Heo:devfrom
yazzang-homelab:fix/telegram-multiselect-state
Aug 6, 2026
Merged

fix(ask): show multi-select state on remote asks#3899
Yeachan-Heo merged 3 commits into
Yeachan-Heo:devfrom
yazzang-homelab:fix/telegram-multiselect-state

Conversation

@yazzang-homelab

@yazzang-homelab yazzang-homelab commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

Make a remote multi-select ask show what is already selected.

  • AskAnswerRequest gains multi and selectedOptions (authoritative option labels).
  • ask.ts fills both on every re-issued remote request, including the per-toggle onRemoteState rebuild.
  • registerInteractiveAnswerSource forwards them into the presentation, so PresentationArbiter publishes selectedOptionIndices and the (N selected) question prefix that the Telegram daemon already knows how to render (☑/☐).
  • A presentation that brings its own navigation control (the ask tool's Next/Done) keeps it; only a durable workflow gate, which presents no controls, still gets the synthesized one.
  • Option renumbering looks past a leading selection marker, so pre-numbered deep-interview options render 1. ☑ Alpha instead of 1. ☑ 1. Alpha.

Why

In Telegram, a multi-select ask reissues a new message per tap until Done. Selecting option 1 produced a byte-identical prompt: no marker, no count, no way to tell what had been picked.

Selection rendering shipped in daemon generation 33, but only along the durable workflow-gate path (presentGatemulti: stage_state.multi). The attended ask tool goes through registerInteractiveAnswerSource, which hard-coded multi: false / selectedOptions: [], and AskAnswerRequest had no field to carry the selection in the first place — onRemoteState only passed selectedCount, and only to enable/disable the navigation control.

Testing

  • packages/coding-agent/test/tools/ask.test.ts — the multi-select toggle test now asserts the second remote request carries multi: true and selectedOptions: ["alpha"].
  • packages/coding-agent/test/sdk-host-wiring.test.ts — new PresentationArbiter test: an ask-tool style presentation publishes selectedOptionIndices, the (1 selected) prefix, and keeps its own Next control.
  • packages/coding-agent/test/notifications-telegram-daemon.test.ts — new render test: pre-numbered options with selectedOptionIndices: [0] render 1. ☑ Alpha / 2. ☐ Beta.
  • All three fail on dev and pass with this change.
  • bun test for ask / sdk-ask-answer-source / notifications-html-format / notifications-telegram-reference / sdk-host-wiring / notifications-telegram-daemon — pass.
  • bun --cwd=packages/coding-agent run check clean; scripts/telegram-daemon-generation-guard.ts reports v43 required generation bump verified (DAEMON_GENERATION 51 → 52, manifest regenerated with --write-manifest); its 48 guard tests pass.

GJC verdict

gajae.pr-review-verdict.v1 needs-human sha256:160159c2fb3f9860b8766665b3a6c6cf1469f61e reviewer:human evidence:local bun test packages/coding-agent/test/tools/ask.test.ts packages/coding-agent/test/sdk-host-wiring.test.ts packages/coding-agent/test/notifications-telegram-daemon.test.ts

  • Target branch is dev
  • bun check passes
  • Tested locally
  • CHANGELOG updated (if user-facing)
  • Verdict above matches the exact PR head, not an earlier commit

The ask tool owns the multi-select loop and re-issues one remote request
per toggle, but AskAnswerRequest carried no selection state: Telegram
posted an identical prompt each time, so nothing showed that option 1 had
been picked. Selection rendering existed only for durable workflow gates,
which take a different presentation path.

AskAnswerRequest now carries multi plus the selected option labels, the
interactive presentation forwards them so the bus publishes
selectedOptionIndices and the "(N selected)" prefix, and a presentation
that brings its own navigation control keeps it instead of the gate's
synthesized Done. Option renumbering now looks past a leading selection
marker so pre-numbered deep-interview options render as "1. checked
Alpha" rather than "1. checked 1. Alpha".

Lore-id: b48e2d17
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: arbiter publication after a toggle, ask-tool remote requests across a toggle, daemon rendering of pre-numbered options
Not-tested: live Telegram client rendering of the checkbox glyphs
Generation 52 is already claimed by the pre-readiness daemon-child exit
diagnostics slice (Yeachan-Heo#3844), which is ahead of this branch in review, so
this slice claims 53 instead of guaranteeing a guard failure for
whichever PR lands second.

Lore-id: 5a3c91d7
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: telegram-daemon generation guard against current dev
@yazzang-homelab
yazzang-homelab force-pushed the fix/telegram-multiselect-state branch from 160159c to 6d1c1b6 Compare August 5, 2026 23:24
@yazzang-homelab

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (11e48d5bc, #3901) — the previous head was CONFLICTING/DIRTY. New head 6d1c1b62b.

One substantive change beyond the rebase: the daemon generation claim moved 52 → 53. #3844 (pre-readiness daemon-child exit diagnostics) also claims 52 and is ahead of this branch in review, so leaving both at 52 guaranteed a generation-guard failure for whichever landed second. dev is still 51; the contract comment now records that 52 belongs to #3844 and that 53 is this slice.

Re-verified on the new base:

  • bun scripts/telegram-daemon-generation-guard.ts (GITHUB_BASE_SHA=11e48d5bc, head 6d1c1b62b) → v43 required generation bump verified; manifest regenerated with --write-manifest so the declaration digests byte-match the tree.
  • bun test packages/coding-agent/test/notifications-telegram-daemon.test.ts packages/coding-agent/test/notifications-topic-registry.test.ts → 607 pass, 0 fail.
  • bun test packages/coding-agent/test/tools/ask.test.ts packages/coding-agent/test/sdk-host-wiring.test.ts → 169 pass, 0 fail.

Merge order: land #3844 first (52), then this (53). If #3844 is dropped instead, this branch should collapse back to 52.

@yazzang-homelab

Copy link
Copy Markdown
Contributor Author

CI on head 6d1c1b62b: test:@gajae-code/coding-agent:shard-1-of-8 failed on one assertion, and it is a timing flake in a test this PR does not touch.

Failure (job):

(fail) telegram daemon /btw reservation and capability boundaries > shutdown admission tracks callback alias persistence before the durable barrier [113.85ms]
  Expected: true
  Received: false
  at packages/coding-agent/test/notifications-telegram-daemon.test.ts:19583:39

Line 19583 is await expect(joinedEffect).resolves.toBe(true), where joinedEffect is effects.join(100). The production budget for the same join is BTW_SHUTDOWN_JOIN_MS = 1_000; the 100 ms budget exists only in the test, so a loaded runner that needs more than 100 ms to quiesce the tracked effect returns false and fails the assertion. Nothing in the assertion is specific to this branch.

Evidence that it is not this PR:

  • git diff dev...HEAD -- packages/coding-agent/test/notifications-telegram-daemon.test.ts does not touch that test, and this branch's only source changes are ask-tool multi-select rendering (ask.ts, bus/index.ts, html-format.ts, the two selection-mark constants in telegram-daemon.ts) plus the generation claim. None of it runs on the shutdown-admission or alias-persistence path, and none of it adds a tracked effect to that join.
  • Locally on this exact head: 5/5 passes for bun test packages/coding-agent/test/notifications-telegram-daemon.test.ts -t "shutdown admission tracks callback alias persistence", and 607 pass / 0 fail for the full daemon + topic-registry suites.

I do not have rerun rights on this repository (rerun-failed-jobs → 403), so the shard needs a maintainer rerun rather than a push from me. I have not force-pushed: a rebase would not change this assertion, and the rest of the run is green.

…l-clock

`effects.join` counts its deadline from the call, and the held-state race
plus its assertion run inside that window before the persistence barrier
is released. A 100 ms budget therefore reports `false` for a barrier that
did hold as soon as the runner is loaded: shard-1-of-8 failed exactly this
assertion on head 6d1c1b6, while the same shard passed on 160159c
where every source file on that path is identical.

The held half of the contract is proven by the race, not by the deadline,
so the deadline only has to outlast the released work. The daemon's own
shutdown join uses 1s; 5s here keeps the assertion load-bearing (a barrier
that never settles still fails) without timing out on a slow runner.

Lore-id: 9b2d5e14
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: assertion still fails when the released work never settles; suite green pinned to one core
@yazzang-homelab

Copy link
Copy Markdown
Contributor Author

Root cause of the failed shard is confirmed and now fixed at head 64956fdff (plain commit, no force-push).

All three red checks are one failure. Affected path validation / evidence producer fails with required affected shards did not succeed, and the Affected path validation gate reports CI_DEV_SHARDS_RESULT: failure. Both are downstream of the single assertion in shard-1-of-8.

It was not this branch's code. On the pre-rebase head 160159c2f that same shard was success, and between 160159c2f and 6d1c1b62b the only change under packages/coding-agent/src/sdk and src/tools is 6 insertions / 4 deletions in telegram-daemon-contract.ts — the DAEMON_GENERATION constant and its comment. The rest of that range is the rebase pulling in #3901's MiniMax catalog rows, which do not touch the Telegram alias path. The ask multi-select code under test is identical between the passing and the failing run.

It was a wall-clock race in the test. effects.join(deadlineMs) computes expiresAt = Date.now() + deadlineMs at the call and returns false once that passes with work still pending:

async join(deadlineMs: number): Promise<boolean> {
    const expiresAt = Date.now() + deadlineMs;
    while (this.#pending.size > 0) {
        const remaining = expiresAt - Date.now();
        if (remaining <= 0) return false;
        ...

The test called effects.join(100) and then ran the held-state Promise.race and its expect before releaseHandlerPersist.resolve(), so the released work had to finish inside what was left of 100 ms. The daemon's own shutdown join uses BTW_SHUTDOWN_JOIN_MS = 1_000 for the same wait; only the test was that tight.

The fix raises that one deadline to 5s and documents why. The contract is unchanged and still load-bearing: the "barrier held" half is proven by the race, not by the deadline, and I verified the assertion still fails (Expected: true / Received: false) when the released work never settles.

Verification on this head:

  • bun test packages/coding-agent/test/notifications-telegram-daemon.test.ts → 553 pass, 0 fail, including pinned to a single core (taskset -c 0).
  • Mutation probe: removing releaseHandlerPersist.resolve() still fails the assertion, so a genuinely stuck barrier is still caught.
  • biome check clean on the touched file.

Generation claim is unchanged at 53, so the merge order behind #3844 (52) still holds.

@Yeachan-Heo
Yeachan-Heo merged commit ae08898 into Yeachan-Heo:dev Aug 6, 2026
33 checks passed
@Yeachan-Heo

Copy link
Copy Markdown
Owner

This item is being moved to the Tier 3 review lane. Further review is deferred until after the current emergency stabilization period.


[repo owner's gaebal-gajae (clawdbot) 🦞]

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.

2 participants