The four Telegram dispatch controllers were partitions of one closure: ~75 factory parameter slots, 7 post-construction back-edge setters, a shared mutable state bag, and load-bearing construction order. One turn record now carries the once-resolved config and all state; the four files remain as implementation with a hand-written leaf type contract (four state-slice types). Rides along: dead generation fence deleted (constant-0 from birth), queuedFinal ||= fix with regression (suppressed exec-approval turns no longer trigger a spurious fallback), collapse resolver/mutator split. Dispatch tests and harness byte-identical to main; live E2E lifecycle proof on the PR.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Centralize Telegram text delivery: page planning and reply-target progression move into telegram-text-delivery.ts, shared by the durable send, edit, and streaming-final paths; bot/reply-threading.ts and per-path split/page logic are deleted. Formatted-to-plain recovery routes through withTelegramPlainFallback everywhere. Restores the projection-cursor invalidation contract on terminal multi-page failures (with regression). Net -321 production LOC. Live E2E on the PR: formatted entity path, plain-fallback recovery, and 2-page chaining.
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121600 (4c951398ef) made default command progress status-only — raw
command text stays out of chat previews. This assertion still expected
the pre-#121600 preview with the raw command line; bisect-confirmed
first-bad at 4c951398ef. Pre-existing failure surfaced by the
dispatch-suite CI gap (#122029). Dispatch suite now 247/247.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveDeliveryTarget (src/channels/turn/durable-delivery.ts:57) reads
OriginatingTo/To — channel-prefixed refs — as its own fallback chain, so
telegram:-100555 is the contract shape for the durable 'to', not the
bare chat id this assertion expected. Pre-existing failure surfaced by
the dispatch-suite CI gap (#122029).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121908 (210aca6de3) added the onPlatformSendDispatch options bag to
final draft-stream updates and updated draft-failures-progress.test.ts,
but missed 19 sibling assertion sites across 6 dispatch test files.
Those files do not currently execute in any CI lane, so main's local
dispatch suite has been red since. Two remaining failures
(context-recovery session-target prefix, draft-failures-progress
preview shape) predate #121908 and are tracked separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Telegram send-funnel parity invariant (durable and streaming funnels must degrade formatted sends identically) was prose-enforced and had drifted: draft previews and message edits only degraded on HTML-parse 400s, not empty-content 400s. One orchestrator (withTelegramPlainFallback) now owns classification, guard, logging, and plain-chunk planning for all 11 call sites; withTelegramHtmlParseFallback and buildTelegramPlainFallbackPlan are deleted. Net -29 production LOC. Behavior change: previews/edits recover empty-content 400s as plain text; degrade logs unify to `degrade=plain-fallback:<trigger>`.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A final reply whose platform send was accepted but whose response was lost
previously ended in silence. Custody that stays unknown after a claimed send
now records durable pendingDeliveryNotice debt; the next same-route turn
delivers one "could not confirm delivery" notice and acknowledges it into the
transcript. Restart recovery completes ambiguous sessions with the same debt
instead of a fire-and-forget notice; the debt survives reset and rollover, and
suppressed notice sends retain it instead of faking delivery. Permanent typed
no-send rejections settle as terminal suppression (no replay, no false
notice); retryable ones restore prepared custody for safe replay. Google Chat
media-only rejections use the typed no-send contract; Telegram native-command
replies join pending-final custody.
Fixes#80362
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Re-lands the reverted #121507 on the current custody contracts. The reply dispatcher now owns direct-send pending-final custody: claim before provider I/O, terminal settlement for delivered/suppressed/failed outcomes, proven no-send stays replayable, ambiguous evidence fails closed — so Gateway restarts can no longer duplicate an already-accepted final reply.
Proof: ClawSweeper local review clean, exact-head ci-gate green, live Telegram E2E (one turn, one final, no duplicates).
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Native approval delivery and resolution now stay bound to the originating or explicitly targeted channel account. Unbound requests fail closed across multiple eligible accounts; trusted reviewer-less SDK callers remain compatible.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Keep raw commands, paths, and provider errors out of ordinary chat while preserving explicit raw diagnostics and structured admin history.
Default command progress is status-only; `/verbose full` and `commandText: "raw"` retain diagnostic detail.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(codex): report harness context window as session contextTokens
Codex app-server reports model_context_window per turn. Carry it through the projector into the run result meta so session rows show the real window instead of the catalog's standard-tier input cap (272k vs 1M for gpt-5.6 models).
* improve(ui): compact chat context popover
Inline stat rows replace boxed tiles; zero-value cost rows and the whole cost section when empty are omitted; provider/model provenance lines are removed because the footer already shows the model; and the popover is narrowed to 300px.
* refactor(codex): split attempt-result assembly out of event projector
* fix(codex): seed attempt context window from startup binding
App-server v2 turn/started omits the core model_context_window, so thread/tokenUsage/updated is the only live carrier. Seed usage-less attempts from the retained startup binding rollout/session window so session metadata cannot regress to the catalog fallback.
* fix(codex): prefer native startup context window
Persisted session contextTokens has no source provenance and may contain the catalog fallback. Keep the minimum window for the conservative rotation fuse, but seed the projector from the native rollout when it is available.
* chore(plugin-sdk): regenerate api baseline (new format)
* revert(gateway): "prevent restart replay after final delivery" (broke 5 CI jobs)
Record channel delivery custody before recipient-visible sends so restart recovery cannot duplicate accepted finals.
Ambiguous legacy markers now fail closed with a visible interruption instead of blind replay.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(telegram): preserve unsent media while deduplicating streamed replies
## What Problem This Solves
A Telegram final reply could resend an attachment already delivered in a streamed block when the legacy mediaUrl still referenced that sent attachment while another mediaUrls item remained. Conversely, unsent legacy-only attachments must not be discarded.
## Why This Change Was Made
The Telegram media-deduplication owner now clears mediaUrl only when its normalized attachment was actually sent, preserving independently unsent legacy attachments and preventing downstream outbound planning from restoring delivered media.
## User Impact
Telegram replies retain every unsent attachment exactly once and no longer duplicate already streamed images in mixed final-message payloads.
## Context
Seven zero-dependency scenarios passed against the actual media owner and outbound planner, including mixed legacy/vector attachments, whitespace normalization, visible-send handling, and remaining-media preservation. The exact frozen campaign baseline passed 844 tests. Existing oxfmt formatting and staged whitespace checks passed; focused Vitest CI remains pending.
* refactor(telegram): inline legacy media dedup check
Use the canonical topic → group → account → default policy for native commands. Disabled scopes now stop before routing and dispatch; sender-specific authorization remains explicit.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: JuniperTheDev <juniperthedev@users.noreply.github.com>
Telegram now treats disabled tool progress as authoritative even when /verbose is enabled. Enabled progress and durable/final messages retain existing behavior.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: Jason O'Neal <jason.allen.oneal@gmail.com>
Co-authored-by: 徐金城0668000897 <xu.jincheng@xydigit.com>
Store resolved Telegram media references on observed messages so repeated quoted-photo turns reuse retained bytes instead of downloading them again. Preserve uncached external-reply media for the active turn and keep cache metadata out of model context.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: Ayaan Gazali <ayaangazali.work@gmail.com>
* fix(telegram): validate command menu language codes
* fix(telegram): enforce exhaustive menu languages
* fix(telegram): harden command menu locale state
Carry canonical direct_messages_topic identity through authorization and config selection so channel DM topics use the same scope as delivery. Keep that identity distinct from message_thread_id.
Follows #120916 by removing duplicate outbound preparation and transport harness code while preserving channel Direct Messages, forum, and bot-private routing.
* perf(doctor): keep telegram doctor enumeration off the runtime graph
Telegram's built doctor artifact reached execa through dist chunking, so a
source-run host (pnpm dev, tsx CLI, vitest) could not require it and silently
dropped all 9 telegram legacy config rules plus its state migration. The
artifact also pulled telegram's runtime stores, making it a 674-chunk outlier
that dominated doctor enumeration.
Root cause: `src/token.ts` took the broad `plugin-sdk/provider-auth` barrel for
`resolveDefaultSecretProviderAlias`, dragging the auth-profile store, provider
runtime, and plugin install graph (execa, kysely, commander) into the closure.
The alias now has a narrow `plugin-sdk/secret-provider-alias` leaf, and
provider-auth re-exports it so its runtime surface is unchanged.
Thread-binding, sent-message, and sticker-cache row shapes, keys, and legacy
sidecar readers move to `*.legacy-state.ts` leaves. The doctor closure keeps
the rows and drops the ACP, session-binding, send, logger, and plugin-runtime
graphs the stores also load.
The postbuild control-plane verifier only required each artifact in a plain
Node child, the one host where these graphs resolve fine, so it proved nothing
about the invariant that broke. It now also walks each built doctor artifact's
static import closure and fails when it reaches the process-spawn graph, which
is the dist-level analogue of the source closure guard.
Guard rules added for provider-auth, acp-runtime, and conversation-runtime; the
telegram boundary test became a real closure assertion instead of a string grep.
* fix(doctor): drop dead export surface from the telegram legacy-state split
Knip and oxlint caught leftovers from the split: the leaves exported helpers
only they use, the store modules re-exported constants nobody imports from them
anymore, and thread-bindings kept a `testing` barrel whose last production
caller was the migration path that now reads the leaf directly. Tests import the
constants from the leaf that owns them, and the reset helper directly.
The closure gate's failure message still interpolated a `host` field left over
from a probe-host approach that was reverted before commit; the existing verifier
test caught it. The gate now has its own coverage: a transitive chunk edge to a
forbidden dependency is reported, while dynamic imports and non-doctor contract
surfaces are not.
* fix(doctor): adopt the upstream telegram thread-binding store split
`main` landed an equivalent thread-binding leaf as `thread-bindings-store.ts`
while this branch was open, so the branch-local `thread-bindings.legacy-state.ts`
is dropped rather than kept as a second path for the same rows.
`state-migrations.ts` now reaches token.js through the lazy import `main` added,
so `token.ts` is no longer in the doctor closure at all. The narrow
`secret-provider-alias` leaf still matters: telegram's contract-api closure
reaches `provider-auth` through `token.ts` on current `main`, which is the same
execa/kysely/commander graph, so the barrel is repaired at its source instead of
being deferred a second time.
* fix(scripts): type the built doctor closure gate for the TypeScript migration
The gate was authored against the `.mjs` script and landed in the `.mts` file
`main` migrated to, so its parameters were implicitly `any` and `check:test-types`
failed. Adds the explicit signatures plus the violation type.
Regenerates the plugin-sdk API baseline: `provider-auth` re-exports the default
secret-provider alias from the new leaf, so its module hash moves while its
runtime export surface stays identical.
Keep quiet Telegram tool progress on the transient draft lane when it exists. Preserve durable delivery for media, execution approvals, and ask-user prompts across direct and queued turns.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* perf(plugins): close the last kysely closure chains and guard reachability transitively
Follow-up to #120698/#120811/#120882: the closure guard's enumerated barrel
bans cannot catch new heavy edges, and two closures still statically reached
kysely on main.
- guard: add a transitive kysely-reachability test that walks static value
imports from every doctor-contract and legacy-setup closure through plugin,
plugin-sdk, and relative core graphs, failing with the full import chain;
type-only and lazy dynamic imports stay allowed
- llm-task/model refs: manifest-model-id-normalization reads snapshots
through a registration-slot runtime bridge (snapshot modules register at
eval; require fallback covers cold processes) and
current-plugin-metadata-state moves its process-scoped facts onto a
globalThis singleton so dual module instances share published state
- telegram: split thread-bindings-store.ts (pure record shapes + legacy-file
readers) out of the acp-runtime-heavy manager, delete the consumer-less
testing export, move the pure bot-user-id token parse to
token-fingerprint.ts, and lazy-import token.js in the async update-offset
detector
llm-task enumeration drops to ~0.8s/157 modules cold; every closure is now
statically kysely-free and stays that way by construction.
* fix(telegram): repoint the native-command menu state at the token-fingerprint parser
Use channels.telegram.groups as the shared default when an account omits groups. Explicit account maps, including {}, remain full replacements. This keeps chat admission and sender restrictions on one policy path and fixes silent multi-account authorization failures.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Replies, previews, and media in channel Direct Messages topics now remain in their originating topic. Bot-private and forum topic routing remains unchanged.
* refactor(plugin-sdk): delete the heavy runtime-doctor barrel
Nothing may pull the state-db/kysely graph through a doctor barrel anymore.
The barrel's remaining heavy exports move to two narrow private-local
subpaths, each with a single purpose:
- doctor-repair-runtime: install-path diagnosis, plugin config removal, and
state-database schema detect/repair (matrix doctor, voice-call lazy import)
- plugin-state-store-runtime: the sync keyed-store factory. It stays out of
plugin-state-runtime because hot channel entrypoints import that at module
load and opening a store pulls the state-database graph.
Doctor closures also stop pulling ssrf-runtime (fetch-guard + gateway net)
for two legacy private-network helpers that live in the lighter ssrf-policy
subpath: mattermost, nextcloud-talk, tlon, matrix.
The closure guard now forbids the two new heavy subpaths instead of the
deleted barrel, so the invariant keeps being enforced where it still applies.
* perf(doctor): keep heavy graphs out of every doctor closure
Doctor enumeration cold-loads each declaring plugin's contract closure, so
one heavy import in a closure is paid by the whole sweep. Four barrels were
still dragging unrelated graphs in for trivial helpers; each is repaired at
the leaf rather than by caching downstream:
- Legacy private-network config migration moves to a config leaf. It only
reshapes records, but lived beside the SSRF runtime (DNS, proxy, logging),
costing mattermost ~2.7s. ssrf-policy re-exports it, surface unchanged.
- Streaming config readers move to a leaf. They read two config keys, but
streaming.ts also formats tool aggregates, pulling tool-display/logging/
acp-core; that cost slack ~2.3s.
- signal took the channel-secret barrel for isRecord; the canonical plugin
record guard is string-coerce-runtime (root AGENTS.md).
- llm-task took the provider-model barrel for parseModelRef, now a narrow
model-ref-parse subpath.
Full doctor enumeration of all 42 declaring plugins, built mode:
legacy config rules 6668ms -> 1265ms, state migrations 184ms -> 127ms.
No plugin remains an outlier; the slowest is now ~380ms against a ~200ms floor.
Public export surfaces of every touched SDK subpath are byte-identical
(verified by diffing built module exports before/after); the API baseline
hashes move only because re-exported declarations emit differently.
The closure guard gains rules for each repaired barrel so the invariant
holds for future closures.
* fix(release): exclude new private-local declarations from the published package
Same pack-path rule as c41da3759f: private-local subpaths ship without d.ts.
* fix(doctor): repair the closure guard violations that break main
The landed guard fails on main: three closures import heavy barrels for one
symbol each. Two more surfaced once the guard learned about the provider-model
barrel. Each gets a narrow subpath at the leaf:
- telegram sent-message-cache + state-migrations took the session-store barrel
(session accessor + state-db) for resolveStorePath -> session-store-paths
- discord thread-bindings.state took the channel-outbound barrel (reply
pipeline + channel registry) for one identity write -> outbound-echo-runtime
- discord model-picker took the provider-model barrel for normalizeProviderId,
which model-ref-parse now exposes beside parseModelRef
The guard also stops walking artifacts of plugins whose manifest declares no
doctor surface. Such a declaration gates the artifact off every enumeration
path exactly as resolvePluginDoctorContracts does, so its closure cost is never
paid; anthropic ("doctorContract": {}) was being held to a cost it cannot
incur. Absent declarations still load eagerly and stay enforced.
Side effect worth naming: discord's built doctor contract now loads again.
On main both discord and telegram fail to require in packaged builds (an
ESM-only transitive dep) and silently lose their repairs; this restores
discord and takes enumerated legacy config rules from 87 to 99. Telegram's
built artifact still pulls execa through dist chunking - a build-level defect
with a different owner, filed as follow-up.
* fix(telegram): prioritize configured commands under menu pressure
* fix(telegram): preserve custom commands under localized menu pressure
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: ayeshakhalid192007-dev <ayeshakhalid192007@gmail.com>