Skip to content

fix(slackbotv2): stay silent on intentional empty completions - #47

Open
joystick-gg[bot] wants to merge 8 commits into
mainfrom
centaur/slack-suppress-empty-final-answer-1788359705
Open

joystick-gg[bot] wants to merge 8 commits into
mainfrom
centaur/slack-suppress-empty-final-answer-1788359705

Conversation

@joystick-gg

@joystick-gg joystick-gg Bot commented Sep 2, 2026

Copy link
Copy Markdown

Problem

When an agent intentionally produces no final reply in a subscribed Slack thread, Centaur posted the literal placeholder Execution completed, but no final text was captured. as the assistant's reply instead of staying silent (reported by tarrence).

Root cause: the Codex App Server renderer (packages/rendering/src/codex-app-server.ts) synthesizes EMPTY_FINAL_ANSWER_TEXT on any successful empty completion (ensureFinalAnswerText() in flush()), and slackbotv2 posts it. The slim-fork cutover dropped the fork's prior suppression of empty completions — see cartridge-gg/agent docs/slim-fork-cutover.md (the cutover checklist explicitly notes the fork "no longer suppresses empty completions"), which regressed this behavior.

Fix

  • Shared renderer (deployment-neutral, opt-in): add suppressEmptyFinalAnswer to CodexAppServerRendererEventMapperOptions. Default is unchanged (upstream still synthesizes the placeholder); when set, ensureFinalAnswerText() skips it on a successful empty completion.
  • slackbotv2: opt in via rendererOptions/fallbackRendererOptions, and skip posting in the plain-text path when the captured text is empty and the run was not interrupted.

Genuine failures stay visible: fail() emits its own Execution failed: … text plus an error, which is not gated by the new option — so true execution failures still surface in the thread. Interrupted runs still render Execution interrupted.

Tests

  • packages/rendering/src/codex-app-server.test.ts: default synthesizes the placeholder; suppressEmptyFinalAnswer yields empty answerMarkdown; a turn.failed still surfaces Execution failed: … with suppression on.
  • services/slackbotv2/test/chat-sdk-emulate.test.ts: a successful completion with no final answer posts no placeholder (was previously asserted to post it); added a companion test proving a failed run with no final answer still shows the failure text.

Validation

  • bun test — rendering (40 pass) and slackbotv2 (270 pass) suites green.
  • tsc --noEmit clean for all changed files (pre-existing test/slack-user.test.ts preconnect type errors are unrelated and untouched).

Note: cartridge-gg/agent docs/slim-fork-cutover.md still states the empty-completion placeholder is "expected"; that note is now stale and should be updated in a follow-up overlay change.

Prompted by: tarrence

kariy and others added 8 commits August 28, 2026 18:50
Retarget publish-images.yml at ghcr.io/cartridge-gg/centaur on
GitHub-hosted runners with docker buildx (this fork has no Depot
project), add the release/c7e-centaur-overlay* branch glob and
contrib/chart/** path trigger, and auto-dispatch the c7e overlay deploy
when canonical-branch images publish.

Add tag-release.yml: durable c7e-<upstream-base>-<counter> tags cut from
main, so deployed commits can never be orphaned by history rewrites.
Gate release-chart.yml's tag minting to the upstream repo so the fork
cannot mint colliding centaur-* tags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ough

Console: add a github_app grant to the broker-credential registry. The
console holds the App private key (client_secret, stored encrypted) and
mints short-lived installation tokens via an RS256 App JWT, rotating
them like any other brokered credential. An idempotent seed migration
provisions the credential from GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID /
GITHUB_APP_PRIVATE_KEY_B64 when set; the chart wires those env vars into
the console from the shared infra Secret.

api-rs: let a tool's pyproject declare where an HTTP secret's value
comes from (source = {type = "token_broker", credential_id = ...}) and
forward the table verbatim into the generated iron-proxy fragment:
tool_discovery parses it, centaur-perms mirrors it, iron-control accepts
typed sources at startup. Falls back to the {placeholder: <secret_ref>}
shape when no source table is given.

Together these let a tool (the deployment's github_auth_headers) ride
broker-rotated GitHub App tokens: raw tokens never enter sandboxes and
no out-of-band CronJob rotates a shared secret.

Pending upstream as paradigmxyz#793 + companion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 3ccdd79)
The tag-fetch step adds an upstream remote to the checkout, so gh
resolved the base repo to paradigmxyz/centaur and the release step
failed with "tag has not been pushed" (c7e-0.1.110-2 needed a manual
gh release create). GH_REPO pins resolution to this repository.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Chat SDK's event dispatch drops file events (file_shared,
file_change) before any callback fires, so nothing today can react to a
file being created or edited. Add a raw-body sidecar (the
channel_created / lateSlackFiles pattern) that, when enabled, creates a
durable workflow run via POST /api/workflows/runs for matching file
events.

Deployment-neutral and default-off: the target workflow name, event
types, channel allowlist, and files.info title-keyword filter all come
from env config (SLACKBOTV2_FILE_EVENT_*). The bot stays timer-free —
any settle/debounce behavior belongs in the target workflow, which can
sleep durably. Per-edit event storms collapse at spawn time via the
idempotency key slack-file-event:{workflow}:{file_id} (optionally
time-bucketed); the dispatch task never throws into the webhook path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
centaur_cc_reader_documents_select (0048) admits only source = 'slack'
rows, so an agent session searching company context never sees any other
document in company_context_documents — including memory notes
(source = 'c7e_memory', source_type = 'memory_note'), the facts a user
explicitly asked the agent to remember. Verified live: the note ranks
first for its own question under the writer role and is invisible to the
reader (0 c7e_memory rows visible).

0051 re-creates the policy with a second arm: company-scoped memory notes
are visible when centaur.slack_include_public is true — a principal
allowed to read public Slack may read company-wide notes — and hidden
otherwise, so a connection with no access settings still fails closed.
Other c7e_memory document types and user-scoped notes stay hidden. The
embeddings policy delegates to this table, so the vector search lane
follows without a change.

etl_context_rls seeds a company note, a user-scoped note, and a memory
update, and pins: public-context readers see the company note; no-public
readers do not; centaur_readonly (source <> 'slack') sees all three.

Claude-Session: https://claude.ai/code/session_01NfpJ3U9pZHEpGD9nwN7c7h

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Rebase note (0.1.129): renumbered 0051 -> 0053 because upstream landed
0051_hermes_harness and 0052_session_execution_request. Prod databases
that already applied this as version 51 must drop that _sqlx_migrations
row before the new api-rs boots; the policy DDL is idempotent.
…lete (#43)

Three gaps in the calendar surface, found while exercising the tool against
a real calendar.

No way to create a Meet. calendar_create_event built a plain event body and
called events().insert() without conferenceDataVersion, so an agent could
book the slot but not the meeting. Meet is minted through the conferencing
subresource: the body carries a createRequest with a fresh requestId and the
call must opt in with conferenceDataVersion=1, or Calendar drops
conferenceData and returns an event with no link. Create and update now take
`conference`, return `meet_link`, and re-fetch once when Calendar answers
before the mint settles. On update the flag only adds a conference to an
event that lacks one; leaving conferenceDataVersion at its 0 default
elsewhere is what keeps an existing Meet intact on an ordinary update.

Edits reached attendees as silence. calendar_update_event passed
sendUpdates="all" only when add_attendees was set, so moving an event,
renaming it, or changing where it happens notified nobody: attendees' copies
moved, their invite emails did not. Verified against a real event -- the
reschedule produced no mail at all. `notify` now defaults to notifying when
the event has attendees and the edit is material: when, where, what it is
called, who else is coming, or a Meet just added. Description-only edits stay
quiet on purpose, so the notices that land keep meaning something.

Materiality is deliberately narrow. The field tests are truthiness, matching
how the function applies them -- it writes a field only when the value is
truthy, so `location=""` edits nothing and must not send mail. Attendee
notification keys off whether the merge actually appended somebody, not
whether a list was passed, so re-inviting an existing guest stays quiet. That
merge also now records the emails it appends, so a repeated address in one
call is added once rather than duplicated.

No way to cancel. calendar_delete_event is new; the tool could create and
update events but never delete one. It notifies by default, since a
cancellation nobody hears about just leaves the meeting on every calendar.

Submitted upstream as paradigmxyz#1522; the calendar functions, CLI
commands, and tests here are byte-identical to that submission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nvUztTAb5Kvkb9PhCnCbT
When an agent intentionally produces no final reply (e.g. a subscribed
Slack thread where it chose not to respond), the Codex App Server renderer
synthesized EMPTY_FINAL_ANSWER_TEXT ("Execution completed, but no final
text was captured.") and Slack posted it as the assistant's reply. The
slim-fork cutover dropped the fork's prior suppression of empty
completions (see cartridge-gg/agent docs/slim-fork-cutover.md), regressing
this behavior.

Add an opt-in `suppressEmptyFinalAnswer` option to the shared
CodexAppServerRendererEventMapper (default preserves upstream behavior)
that skips the synthesized placeholder on a successful empty completion.
slackbotv2 opts in via rendererOptions/fallbackRendererOptions and skips
posting in the plain-text path when the captured text is empty and the run
was not interrupted. Genuine failures are unaffected: fail() emits its own
"Execution failed" text and an error, so true execution failures remain
visible in the thread.

Tests:
- packages/rendering: default synthesizes the placeholder; suppression
  yields empty answerMarkdown; failures still surface "Execution failed".
- slackbotv2 emulate: a successful empty completion posts no placeholder;
  a failed run with no final answer still shows the failure text.
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