Skip to content

fix(ai,sharing): long ask_user options reach the user, a write-secretary invites for the owner's own events, a partly failed turn ends with a readable receipt (Refs #908) - #940

Merged
alex-mextner merged 7 commits into
mainfrom
fix/secretary-invites-askuser-20261031
Nov 4, 2026
Merged

alex-mextner merged 7 commits into
mainfrom
fix/secretary-invites-askuser-20261031

Conversation

@alex-mextner

@alex-mextner alex-mextner commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Refs #908 #909 #526 #914

A write-secretary asked the bot to create an event in the delegating owner's calendar and invite three people. Three things went wrong in that one conversation: the question the bot asked never reached the user, the bot could not invite for the owner's event and invented Telegram ids for the named invitees, and after a partly failed run the reply was a numbered "attempt" list with no event names. This PR fixes the three production paths. The incident is described without any private data in docs/plans/2026-10-31-secretary-invitation-incident.md.

What changed and why

ask_user options of any length are delivered (#908, #914). Telegram refuses callback_data over 64 bytes, and a 45-character Russian option is 87 bytes in ai_btn:<text>, so the whole question failed to send (BUTTON_DATA_INVALID) and the model got an error it could not act on. Options that do not fit now go out as ai_opt:<index> buttons and the tap handler maps the index back to the exact option text; short options keep the inline form. The answer is shown as one bounded line. Two taps, a failed acknowledgement edit or a failed ctx.answer() reach the AI once: the answered message is claimed in one bounded process-wide set, released when the continuation cannot start. The earlier option-length and keyboard-size caps were guesses and are removed; an empty question or option and more than 100 buttons are still rejected.

A write-secretary can invite, resend, cancel and read the roster for the owner's own personal events (#909). send_invitation, resend_invitation, cancel_invitation and get_invitation_status accept owner_id. The delegated paths use an owner-only personal-event lookup, so a group event or an event somebody else organises is an explicit scope denial, and write access is checked again after the recipient lookup (a revoked secretary can no longer send). Recipient identity is unchanged: invented ids are still refused; only the requester's own id is now accepted as verified and the rejection hints for a delegated calendar no longer send the model to the picker. A foreign invitation id answers exactly like a missing one (no existence oracle), and every denial carries mutationState: not_applied, so the receipt does not ask the user to check the calendar after a denied write. Behaviour change: a secretary can no longer act on group-owned events, only on the owner's personal events.

A partly failed turn ends with a readable receipt (#526). The turn-finish guard used to replace the model's text with the numbered attempt ledger. The reply is now built from the write ledger: each event or invitation is named with its title and local time, a failed write is shown once as not done, a later success of the same requested event replaces the earlier failures, and an unknown outcome is reported as unconfirmed, never as delivered. A delegated calendar is named by its owner when readable and neutrally otherwise, group chats still hide targets, markup in names is neutralised and a hostile start_at cannot make the receipt throw. Recurring creates are told apart through an RRULE normaliser. Messages exist in ru and en.

Debugging procedure. AGENTS.md and the deploy runbook now make the per-chat AI debug log and the Docker log the first step of any problem analysis (private temporary copy, ranges rather than the whole file, FINAL is the intended reply and not proof of delivery, no raw ids or chat text in the repository).

Release. pyproject.toml and uv.lock go from 0.3.1 to 0.3.2.

Verification

  • Reviewed: independent review diff --staged rounds before each commit (recorded under the task codes of ask_user fails with BUTTON_DATA_INVALID for options over 64 bytes of callback_data #908, A write-secretary cannot send invitations for an event in the owner's calendar; the model invented Telegram ids for named invitees #909 and Write receipts after a partly failed run list 'Попытка N' lines without event names and keep failures a later attempt already fixed #526); the review findings fixed before commit are listed in the plan file.
  • Local single files, this branch: the four Python suites test_local_fallback_staging.py, test_odroid_stage_qa_installer.py, test_stage_qa_browser_installer.py and test_odroid_stage_qa_teardown.py pass when run with python3 -m unittest. test_local_fallback_staging.py fails under bun test only because bun's temporary directory name contains a dot; it was not changed for that.
  • Rebased on main d0bc076 (per-chat serialization and Claude credential failover) with one integration commit: the ask_user logging tests needed the new reserveChatTurn dependency, and the owner_id parameter of the invitation tools had pushed the tool catalog 151 characters over its 36000-character budget, so the shared owner_id description is shortened (it appears in 19 tools).
  • Owner's server, exact head (Bun 1.4.3, not the 1.4.2 CI pin): bun install --frozen-lockfile 0, bun run lint 0, tsc --noEmit 0, bun test: 9187 pass, 16 skip, 6 fail across 535 files, none of them in this change. Two Python installer suites cannot run there because the server has no system git, and test_odroid_stage_qa_teardown.py hits the known intermittent bash 5.3 crash. Three Chromium tests (playwright-pool, intent-catalogue, ssg-browser) failed because that run lacked the Chromium library environment; the same three files pass (4 pass, 0 fail) with LD_LIBRARY_PATH and FONTCONFIG_FILE set. So the server run is not fully green and hosted PR CI is the check of the exact head.
  • Hosted PR CI and Stage QA: see the checks of this PR.

Not covered

  • Stage QA drives one Telegram account with one-line text, so it cannot exercise the secretary flow (two accounts) or a tap on a long ask_user button. Those behaviours are covered by unit tests: test/services/ai/tool-handlers/ask-user.test.ts, test/bot/handlers/ai-button-answers.test.ts, test/services/ai/tool-handlers/sharing-secretary-invitation*.test.ts, test/services/ai/write-receipt.test.ts, test/services/ai/write-outcomes.test.ts. The conversation-level acceptance (a secretary invites a named person; a long-option question is answered once) is a separate, owner-authorised live test after the deploy.
  • Whether Telegram carries a very long button label whole is unverified (ASK_USER: verify Telegram's keyboard and button-label limits (only the 100-button cap is still enforced) #914).

Deferred findings

Tracked, not part of this change: #910 reason class of a rejected invitation, #911 stale not-deleted line after a retried delete or update, #912 chat-logging stores an ask_user answer as a turn before the handler decides, #913 unreadable-keyboard alert wording, #915 roster read for secretaries in a group, #916 receipt wording for an unreadable owner name, #917 tap handler claim ordering, #918 cancel and resend denials carry no mutation state, #919 test fixtures with production-looking values, #920 write ledger edge cases, #921 all-day events west of UTC, #922 leftover scope gaps of the secretary tools, #923 taps ignored when the question ends in a checkmark line. #526, #914 and #920 to #923 stay open after this merge.

…ed tap reaches the AI once (Refs #908, #914)

An option longer than Telegram's 64-byte callback_data made the whole question
undeliverable. Such options are now sent as ai_opt index buttons that the tap
handler maps back to the exact option text; shorter ones keep the inline form.
The tap handler claims the answered message before the AI continues, releases
the claim when the continuation cannot start and treats a failed ctx.answer as
non-fatal, so racing or repeated taps reach the AI once. The answer is shown
as one bounded line. The earlier option-length and keyboard-size caps are
removed; the 100-button cap stays and an empty option is still rejected.
docs/reference/telegram-limits.md and the tests record the limits.
…osters for the owner's own personal events (Refs #909)

send_invitation, resend_invitation, cancel_invitation and get_invitation_status
accept owner_id, so a secretary whose access covers a calendar can address an
event in the owner's calendar. The delegated paths look the event up with an
owner-only personal-event lookup, so a group event or an event another person
organises is an explicit scope denial, and write access is checked again after
the recipient lookup. Recipient identity is unchanged: invented ids are still
refused. A foreign invitation id now answers exactly as a missing one, and every
denial carries mutationState not_applied. The system prompt, tool schemas and
sharing spec describe owner_id.
…s and was not written instead of the numbered attempt ledger (Refs #526)

When one write of a turn failed, the whole reply became numbered "attempt N"
lines with no event titles. The reply is now built from the write ledger: each
event or invitation is named with its title and local time, a failed write is
shown once as not done, a later success replaces the earlier failures of the
same requested event, and an unknown outcome is reported as unconfirmed. A
delegated calendar is named by its owner when readable and neutrally otherwise,
group chats still hide targets, names are neutralised against markup, and a
hostile start_at cannot make the receipt throw. Recurring creates are told
apart through an RRULE normaliser. Messages exist in ru and en.
…record the secretary-invitation incident plan (Refs #908)

AGENTS.md gains a section that makes the per-chat AI debug log, the Docker log
and the host logs the first step of any problem analysis: copy the log to a
private temporary file outside the checkout, read ranges rather than the whole
file, treat FINAL as the intended reply rather than proof of delivery, and keep
raw logs, ids and chat text out of the repository. The deploy runbook
diagnostics use the same procedure with a <chatId> placeholder. The plan file
for the incident (a secretary asking the bot to create an event in the
delegating owner's calendar and invite three people) lists the fixed items,
the review findings fixed before commit and the deferred table #910-#920.
…n the secretary, receipt and recipient-approval tests with real types (Refs #908)
…ool catalog budget and the per-chat turn gate deps (Refs #908)

After rebasing onto the per-chat serialization change, the ask_user logging tests lacked the new reserveChatTurn dependency, and the four invitation tools' owner_id parameter pushed the tool catalog 151 characters over its 36000 budget. The shared owner_id description is shortened (it appears in 19 tools) and still says it is the calendar owner's ID for a secretary.
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