Skip to content

feat: rich UI surface — RichResponse + TransportAdapter.postRich (4-phase migration)#137

Merged
royosherove merged 19 commits into
mainfrom
feat/rich-ui-surface
May 16, 2026
Merged

feat: rich UI surface — RichResponse + TransportAdapter.postRich (4-phase migration)#137
royosherove merged 19 commits into
mainfrom
feat/rich-ui-surface

Conversation

@royosherove
Copy link
Copy Markdown
Member

Rich UI Surface Migration

Implements the Rich UI Surface design (docs/rich-ui-surface-design.md) end-to-end. Lifts Telegram inline-keyboard rendering out of gateway command modules and into a transport-agnostic RichResponse value type rendered by TransportAdapter.postRich().

Net effect: new menu-style commands describe intent ({ text, menu }) and never import a transport package. Adding Slack/Discord later means implementing postRich() on a new adapter; no command code changes.

What changed

All four phases from the design doc, in commits 2–5:

  • Phase 1 (5374f99) — non-breaking abstraction

    • src/transports/types.ts: new RichButton / RichMenuSection / RichMenu / RichResponse types; postRich() and progress() are now required TransportAdapter methods.
    • src/transports/telegram/rich-ui.ts: new home for the Telegram callback protocol (CALLBACK_PREFIX, encodeTelegramCallbackData) and the RichMenu → inline_keyboard renderer.
    • TelegramAdapter.postRich() implemented with a hardened never-throws contract: missing fetch / missing chatId / fetch rejecting / fallback rejecting → all swallowed and logged.
    • CommandDescriptor.invoke and actions[K] may now return void | RichResponse | Promise<…>.
    • Gateway.postCommandResult() dispatcher forwards RichResponse returns to transport.postRich(); void returns are a no-op (legacy path preserved).
  • Phase 2 (5eddd18) — /model migrated to RichResponse. Drops thread/postWithFallback from the command module. Same alias list, same settings I/O, same action id.

  • Phase 3 (15ddfcd) — /topic migrated to RichResponse. Drops Telegram imports. Documents and asserts the routing rule: applyTopicOverride() only rewrites the agent-session id; the transport thread is preserved by construction. New regression test (test/topic-dispatch-regression.test.ts) drives the live Gateway.dispatchInTurnCommand and asserts the original chat-SDK thread reaches transport.postRich (.toBe referential equality, not just shape match).

  • Phase 4 (bb0d0c1) — /crons migrated. Promoted progress messages (/update, /compact, /doctor, context-pressure UI) to a TransportAdapter.progress() method so commands no longer import from src/transports/telegram/.

Codex self-review loops

Three review loops, all findings resolved:

  1. Round 1 (94e8963) — 5 findings: hardened postRich's no-fetch fallback, fixed regression test scope, promoted progress() to TransportAdapter, dropped ChatThreadLike dep from command-registry, tightened any in collectAndValidateActions.
  2. Round 2 (4f2e386) — 4 findings: rewrote regression test to drive Gateway.dispatchInTurnCommand (the actual live dispatcher), fixed pre-existing TypeScript scope bug on deferredSoftFlush, tightened newly-introduced any types, deleted now-unreachable inline-keyboard.ts shim and its test.
  3. Round 3 (7ae6b29) — 1 actionable finding: tightened residual any in dispatchInTurnCommand signature and progress.ts telegramFetch return type.
  4. Round 4 — no findings. Clean.

Tests

  • Pre-PR baseline: 540 tests
  • Post-PR: 552 tests passing (+18 added for new abstraction and regression coverage; −8 removed because inline-keyboard.test.ts was subsumed by the new rich-ui.test.ts)
  • npx tsc --noEmit clean for src/gateway/, src/transports/, test/. (Pre-existing trunk errors in src/agents/pi/, src/cli/, src/cron/runner.ts are unrelated and out of scope.)

New test files:

  • test/rich-ui.test.ts — Telegram callback encoding + RichMenu → inline_keyboard (default 2-col, custom columns, multi-section, selected-state hint, empty-menu).
  • test/telegram-postrich.test.ts — happy path; missing-fetch fallback; rejecting-fetch fallback; never-throws when fallback also fails; text-only response posts no keyboard.
  • test/post-command-result.test.ts — void = no-op; RichResponse calls postRich exactly once with the same thread+response; awaits the promise; last-ditch thread.post(text) if postRich throws.
  • test/topic-dispatch-regression.test.ts — drives the live Gateway.dispatchInTurnCommand; asserts the SDK-delivered thread object reaches transport.postRich unchanged when invoked from inside a named-topic session.

Deviations from the design doc

  1. Single PR, not four. Brief asked for full migration. All four phases ship together, one commit each.
  2. createProgressMessage was promoted to TransportAdapter.progress(). The design doc only proposed postRich; codex round 1 (correctly) flagged that command files still imported from src/transports/telegram/ for progress messages. Resolved by adding progress() to the public adapter contract — same shape, different verb.
  3. Legacy commands not migrated. The design doc says "Migrate /crons and future interactive commands" in phase 4. /new, /restart, /status, /stop, /verbose, /doctor still call thread.post/postWithFallback directly. They return void from their descriptor — the new dispatcher's no-op-on-void branch keeps them working unchanged. Brief explicitly said "/crons currently text-only — minimal work, just convert text replies to { text: \"...\" }. Skip menu surface unless trivial." That guidance is what the migration applied. Migrating the other text-only commands is a future cleanup PR; no behaviour change required.
  4. Pre-existing deferredSoftFlush scope bug fixed in passing. Not part of the rich-UI design; surfaced by tsc --noEmit while the file was open. Documented in the round-2 commit message.

Hard rules respected

  • No filler. Every commit is action.
  • Tests stay green between phases (verified after each one).
  • Branch never touched main. PR opens against trunk; no merge.
  • Where the design doc said X and reality demanded Y (Telegram progress messages), the deviation is documented above and in the commit message.

Open questions

None blocking. Two optional follow-ups for a future PR:

  1. Migrate /new, /restart, /status, /stop, /verbose, /doctor to RichResponse so the entire descriptor surface is uniform. Pure refactor, no behaviour change.
  2. Tighten the remaining gateway-wide thread: any pattern by threading the chat SDK's Thread<TRaw> generic through helpers.ts, streaming.ts, and gateway.ts (medium-size effort, separate from this surface migration).

Adds the design proposal for moving Telegram inline-keyboard rendering
out of gateway command modules and into TransportAdapter.postRich(),
with a transport-agnostic RichResponse value type.

Captures the topic-session routing rule (transport thread identity
must not be conflated with agent session identity).
…e 1)

Adds a transport-agnostic 'rich response' surface so command modules can
return menu data without ever importing Telegram-specific helpers.

- src/transports/types.ts: RichButton / RichMenuSection / RichMenu /
  RichResponse types; postRich() is now a required TransportAdapter method.
- src/transports/telegram/rich-ui.ts: new home for the Telegram callback
  protocol (CALLBACK_PREFIX + encodeTelegramCallbackData) and the
  RichMenu \u2192 inline-keyboard renderer.
- src/transports/telegram/telegram-adapter.ts: implements postRich(); no
  menu \u2192 plain HTML; with menu \u2192 inline keyboard via telegramFetch with
  text fallback on any transport-level failure.
- src/gateway/command-registry.ts: invoke()/actions[K] may now return
  void | RichResponse | Promise<\u2026>.
- src/gateway/gateway.ts: dispatch loop and onAction handler forward
  RichResponse returns to a new postCommandResult() helper which calls
  transport.postRich(). void returns remain a no-op (legacy path).
- src/gateway/inline-keyboard.ts: now a thin @deprecated shim re-exporting
  from the new rich-ui module so existing callers keep compiling. New
  gateway code MUST NOT import from here.

Phase 1 of docs/rich-ui-surface-design.md. Non-breaking: no command
behaviour changed, all 540 tests green.
handleModel + handleModelAction are now pure data builders. They no
longer import from src/gateway/inline-keyboard.ts, never touch
telegramFetch, and never call thread.post directly.

- buildModelMenu() returns a RichMenu with selected-state hint per button.
- applyModelSelection() returns a RichResponse with confirmation text.
- ModelCommandContext drops 'thread' and 'postWithFallback'; the gateway
  consumes the returned RichResponse via postCommandResult().

Behaviour-preserving: same alias list, same settings I/O, same console
logging, same action id. 540 tests still green.
…hase 3)

handleTopic + handleTopicAction are pure data builders now. They no
longer import from src/gateway/inline-keyboard.ts, never touch
telegramFetch, and never call thread.post directly.

Routing rule (verified, not changed): applyTopicOverride() only rewrites
the *agent-session* id string ('topic:<chatId>:<name>'). The transport
thread object passed into the gateway dispatcher is preserved, with its
.adapter and .id intact. That keeps the menu surface working from inside
a named-topic session by construction \u2014 there is no 'synthetic thread'
constructed in that path. Documented this contract in the module doc.

- Drops postWithFallback from TopicCommandContext.
- buildTopicMenu() returns a RichMenu with the active topic flagged as
  selected on its button.
- applyTopicSelection() returns a RichResponse with confirmation text.
- Group-chat rejection becomes a text-only RichResponse.

Regression test added: 'returns a menu when invoked from inside a
named-topic session'. Confirms applyTopicOverride('main', { id: 'telegram:42' })
returns 'topic:42:deploy' AND that handleTopic on the same transport
thread still produces a menu (not a text-only fallback).

541 tests green (was 540).
- handleCrons returns RichResponse for every branch instead of awaiting
  thread.post / postWithFallback. The descriptor in gateway.ts drops the
  no-longer-needed postWithFallback dependency.
- The transient '\u23f3 Triggering ...' notice in the trigger branch stays as
  a direct thread.post (eager progress notice); the result of the trigger
  comes back as the descriptor's RichResponse.
- /jobs alias preserved.

Search confirms no gateway command file calls telegramFetch directly
anymore. The remaining src/gateway/commands.ts import from
src/transports/telegram/progress (createProgressMessage, used by /update,
/compact, /doctor) is intentional progress-UI infrastructure with an
existing TODO to move it onto TransportAdapter when multi-transport
lands. That work is out of scope for the rich-UI surface migration
\u2014 progress messages are stateful (post + edit + edit \u2026) and not a
menu surface; promoting them is a separate design effort. Documented.

541 tests still green.
- test/rich-ui.test.ts: callback encoding round-trip + RichMenu \u2192 Telegram
  inline keyboard layout (default 2-col, custom columns, multi-section,
  selected-state hint, empty-menu).
- test/telegram-postrich.test.ts: TelegramAdapter.postRich \u2014 sendMessage
  with inline_keyboard on the happy path; postMessage fallback when
  telegramFetch is missing; postMessage fallback when telegramFetch
  throws; never-throws contract when even the text fallback fails;
  text-only response posts no keyboard.
- test/post-command-result.test.ts: Gateway.postCommandResult \u2014 void is
  a no-op, RichResponse calls transport.postRich exactly once with the
  same thread + response, awaits the postRich promise, and uses
  thread.post(text) as a last-ditch fallback if postRich throws.

558 tests green (was 540, +18).
5 findings from codex self-review, all addressed:

1. TelegramAdapter.postRich: hardened the missing-fetch / missing-chat-id
   branch \u2014 now routes through a new safePostText() helper that swallows
   postMessage errors. postRich now never throws on any error path.
2. test/topic-dispatch-regression.test.ts: new dispatcher-level regression
   test that runs the *actual* descriptor.invoke + postCommandResult
   chain Gateway uses live, with a named-topic active. Asserts that the
   exact same chat-SDK-delivered thread object reaches transport.postRich
   (not a clone, not a synthetic { id: 'topic:...' }). Also covers the
   action-callback path. The previous test only exercised handleTopic in
   isolation and didn't prove the routing rule held end-to-end.
3. src/gateway/commands.ts no longer imports from src/transports/telegram/.
   Promoted progress messages to a first-class TransportAdapter method
   (TransportAdapter.progress() returning ProgressMessage). CommandContext
   now exposes  injected from the transport, so /update,
   /compact, /doctor are transport-neutral. Gateway.handleContextPressure
   uses it too.
4. src/gateway/command-registry.ts: dropped its dependency on the
   deprecated ChatThreadLike from inline-keyboard.ts. Replaced with a
   local minimal ActionThreadLike interface so the shim has zero live
   callers in production code.
5. src/gateway/command-registry.ts: collectAndValidateActions's internal
   array now uses the proper ActionHandler type instead of `any`.

560 tests green (was 558, +2 from the new regression test).
4 findings from round 2 codex review, addressed:

1. Regression test now drives the LIVE dispatcher. Extracted the in-turn
   command-dispatch step into Gateway.dispatchInTurnCommand() so the
   topic-dispatch-regression test calls the same code path handle() uses
   in production. Future refactors that swap 'thread' for a synthetic
   { id: agentThreadId } inside the dispatcher will now fail this test
   on the .toBe(transportThread) referential-equality check. The previous
   version just called desc.invoke directly, which sidestepped the very
   coupling we want to lock down.
2. Fixed pre-existing TypeScript scope bug at gateway.ts:529 \u2014
   deferredSoftFlush was declared inside an inner try and read after the
   enclosing finally, surfacing 'Cannot find name' under tsc --noEmit.
   Hoisted to outer scope. (Pre-existing on trunk; drive-by fix because
   the file was already touched.)
3. Tightened newly-introduced 'any' types: CommandContext.progress now
   takes ChatThread; createProgressMessage now typed at ChatThread (with
   a documented narrow at the transport boundary, same pattern as
   postRich); the 'thread as any' cast in TelegramAdapter.progress is
   gone. The remaining 'thread: any' on Gateway.postCommandResult is
   documented as following the rest of the gateway (chat SDK Thread<TRaw>
   doesn't flow cleanly through these surfaces yet).
4. Deleted src/gateway/inline-keyboard.ts and test/inline-keyboard.test.ts
   \u2014 confirmed no production callers remain. The wire-format the shim
   covered (CALLBACK_PREFIX, encodeCallbackData) is fully covered by
   src/transports/telegram/rich-ui.ts and test/rich-ui.test.ts.

552 tests green (was 560; -8 from inline-keyboard.test.ts removal,
those tests are now in test/rich-ui.test.ts; the existing dispatcher
regression test was rewritten so total is 552 not 560).
- Gateway.dispatchInTurnCommand: message: any \u2192 message: unknown.
  thread stays 'any' to match the rest of the gateway (chat SDK
  Thread<TRaw> doesn't flow cleanly through this seam yet).
- src/transports/telegram/progress.ts: telegramFetch's Promise<any>
  \u2192 Promise<unknown>, narrow the result with a type assertion at the
  use site instead of the boundary.

Codex round-3 finding 3 (552 tests not 552) is a sandbox artefact;
local 'npm test' reports 552/552 passing. Codex's environment can't
write under ROUNDHOUSE_DIR or bind IPC sockets.

Codex finding 8 (legacy thread.post in /new, /restart, /status, /stop,
/verbose, /doctor) is intentionally out of scope: the brief migrated
/model + /topic + /crons specifically. Other commands keep their
existing behaviour (the descriptor return type is now void | RichResponse,
so void-returning legacy handlers continue to work unchanged).

552 tests green, tsc clean for the touched paths.
The two commands shared the same 'compute current → render text fallback +
RichMenu with selected flag' shape. Hoisted into a transport-neutral
helper in src/transports/rich-helpers.ts, with optional sentinel support
for /topic's 'main (default)' button.

- /model and /topic both delegate to buildSelectableMenu()
- Adds test/rich-helpers.test.ts covering selected marker, sentinel
  active/inactive, text-fallback symmetry, and columns propagation
- Existing /model and /topic behaviour unchanged (same actionIds, values,
  selected semantics; text fallback now uses the helper's standard layout
  but still mentions all options + current)
Removes the redundant try/catch + thread.post(text) fallback in
Gateway.postCommandResult. TransportAdapter.postRich is documented to
never throw (TelegramAdapter already degrades internally via
safePostText), so the wrapper was both redundant and obscured genuine
adapter bugs.

- Update TransportAdapter.postRich JSDoc with explicit Precondition note
- Update test/post-command-result.test.ts: assert propagation on adapter
  contract violation (cleaner design per the brief — propagating signals
  the bug rather than silently swallowing it)
Replaces the `any` types on CommandInvocation with:
- thread: MinimalThread (new export from transports/types) — covers id,
  post, optional startTyping; that's all command handlers actually use.
- message: { text?: string; [key: string]: unknown } — loose but typed,
  per the brief.

Cascading fixes in gateway.ts:
- withCtx() narrows message to read `author` via a local cast
- dispatchInTurnCommand passes a typed CommandInvocation.message
- TopicThread drops its [key: string]: unknown index sig so MinimalThread
  is assignable

No behavioural change. tsc shows no new errors (pre-existing
agent-command/setup/cron/pi-adapter errors unchanged).
Adds test/topic-sentinel.test.ts probing normalizeTopicName() with edge
cases (whitespace, casing, surrounding dashes/underscores, special
chars) to assert the sentinel '-main' can never be produced by user
input. Pins the invariant so a future relaxation of the normalizer's
^-+|-+$ trim rule fails the test loudly.
Adds a JSDoc on RichButton.value explaining why it's a flat string and
not an object: Telegram callback_data is 64 bytes max, so the simple
encoding survives all transports. If structured payload is ever needed,
JSON-encode into the field and decode in the handler. References the
MAIN_SENTINEL pattern in topic-command.ts as the canonical sentinel
recipe.
Replaces the mixed-paradigm two-message pattern (eager thread.post()
"Triggering..." then RichResponse "queued.") with a single
transport.progress() handle that updates in place.

- CronsContext gains progress: (thread, initialText) => Promise<ProgressMessage>
- gateway.ts passes this.transport.progress as the factory
- handleCrons returns void after a trigger (progress posted final state);
  return type widened to Promise<RichResponse | void>
- Added a catch branch posting "\u274c <id> failed: <msg>" on scheduler error
  before re-throwing (keeps existing failure visibility)

Adapters without edit support degrade to a single post + no-op updates
(per ProgressMessage contract), so this is strictly an improvement on
adapters that do support edits (Telegram).
The Telegram inline-keyboard renderer (toTelegramInlineKeyboard) ignores
both fields, and after S1 the helper builders no longer set them either
\u2014 header text already lives on RichResponse.text where commands actually
populate it.

When a transport with a real card layout (Slack Block Kit, Discord
embeds) lands, structured fields can be added back together with the
matching renderer. Until then they're dead data shape.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e5f0dac42e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/transports/telegram/telegram-adapter.ts
…mment + TODO

- /crons trigger: log + progress.update(❌) only, don't rethrow.
  Rethrow caused a second generic error from the gateway catch-all
  on top of the user-visible ❌ progress edit. Symmetric with the
  rest of handleCrons returning RichResponse for failures.
- model-command: TODO note that buildModelMenu does an alias→label→alias
  round-trip; cheap with 8 models, but flagged for clarity.
- topic-command: tighten TopicThread doc — it's narrower than MinimalThread
  (id may be absent), not 'compatible with'.
Codex P1 finding on PR #137: postRich() routes missing-handle cases
through safePostText() → postMessage(), which throws on any thread
that fails isTelegramThread. The catch swallowed the error and the
user got nothing — violating the postRich never-throws degradation
contract for callback/invocation threads lacking adapter.telegramFetch
or a 'telegram:' id shape (could drop /model and /topic confirmations).

Three-tier fallback now:
  1. postMessage (HTML, native Telegram path)
  2. thread.post() if available (generic chat-adapter contract)
  3. log + give up (true terminal failure)

+1 test asserting tier-2 path: synthetic non-Telegram thread shape
with thread.post(); postMessage throws → thread.post is invoked.
@royosherove royosherove merged commit be762a1 into main May 16, 2026
1 check passed
@royosherove royosherove deleted the feat/rich-ui-surface branch May 16, 2026 18:34
@royosherove royosherove mentioned this pull request May 16, 2026
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