Skip to content

feat(workspace): Discuss and Dismiss on asks (abilityai/trinity-enterprise#747, #748) - #3181

Merged
vybe merged 7 commits into
devfrom
feature/ent747-748-ask-discuss-dismiss
Oct 6, 2026
Merged

vybe merged 7 commits into
devfrom
feature/ent747-748-ask-discuss-dismiss

Conversation

@dolho

@dolho dolho commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Changes

  • client_portal/asks/{router,service,models}.py — POST …/asks/{id}/dismiss, POST …/asks/{id}/discuss (person gate, uniform 404, rate-limited), dismissed status, discussion_chat_id, chat reads include the discussed ask, the turn-context provider.
  • services/ask_service.py — dismiss() as a sink ending; operator_resume_service._framed_ending dismissed wording.
  • db/operator_queue.py — cancel_item(disposition=), set_discussion_link (CAS on the stored context + status='pending'); database.py facade.
  • services/operator_queue_service.py — workspace_discussion_id joins _PLATFORM_CONTEXT_KEYS (stripped from agent content at both ingestion boundaries).
  • Frontend — PortalAsks.vue (Discuss / Dismiss / Undo), stores/clientPortal.js (dismissAsk / undoDismissAsk / commitDismissAsk / discussAsk), portalChatAsks.js (discussed ask drawn and pinned in its chat), PortalConversation.vue (Send as answer), operatorQueue.js + PortalInboxList.vue (Dismissed label), PortalAskContext.vue (markdown).
  • Prompt copy in platform_prompt_service.py + config/trinity-meta-prompt/prompt.md; MCP get_my_ask description.
  • Docs: feature-flows/operating-room.md → Discuss and Dismiss; requirements/core-agent.md §5.40.

No schema change: disposition and context are existing TEXT columns, so no SQLite or Alembic migration. No submodule pointer moves.

Test Plan

  • cd tests && pytest unit/test_ent747_748_ask_discuss_dismiss.py — 38 pass (SQLite)
  • unit/test_ent747_748_ask_writers_pg.py (requires_postgres) — 10 pass on SQLite + real PostgreSQL 16 (CAS link race, dismiss-vs-answer race, chat prefilter)
  • Existing ask / ending / turn-context / guard suites — 1,595 + 433 + 301 pass
  • Frontend npm run test:unit — 4,539 pass (incl. new portalAskDiscussDismiss.mount.spec.js, raw-colour / loading-gate / source-text ratchets); check:tokens clean
  • MCP server tsc --noEmit + npm test — 627 pass
  • Manual, both themes: Dismiss + Undo, Discuss → chat → Send as answer, Continue discussion (in progress on a local stack)

Fixes abilityai/trinity-enterprise#747
Fixes abilityai/trinity-enterprise#748

🤖 Generated with Claude Code

Dismiss (Abilityai/trinity-enterprise#748): one click ends a pending
question or approval as disposition=dismissed (status stays cancelled)
through the ask ending sink — audit, thin broadcast, and the filer's
wake with its own framing. 5s client-side Undo window; nothing is sent
until it lapses. Addressee only (person gate, uniform 404); a dismiss
that loses a race, or lands past the deadline, is a no-op.

Discuss (Abilityai/trinity-enterprise#747): opens one chat per ask with
the asking agent (CAS link in a new platform-only context key), titled
after the ask, with the ask as its tile; a turn-context provider puts
the ask (id, kind, live status, options) in every turn there. The ask
stays one pending row; a question can be answered from the composer
with "Send as answer" (written as response).

Also renders agent markdown in the ask context's "Your recent answers"
list, the surface #3115 missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho dolho added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 2, 2026
dolho and others added 2 commits October 2, 2026 11:01
The Inbox turned Discuss's open-thread into a bare /workspace/c/<id>
push, and the #3140 guard read the just-created chat (not yet in the
thread list) as not the viewer's. Route it through the shell's
openThread, which adopts the id, and refresh the list. The chat tile
now forwards open-thread too — its Discuss did nothing before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author
image

@dolho
dolho requested a review from vybe October 2, 2026 08:26
…#747)

An answer that woke an opted-in agent left the woken run's output in the
execution history only — the person saw the agent start and never saw
what it did. The resume run now carries the Workspace destination (the
ask's discussion chat, else its attached chat; only when the answerer is
the addressee), so the ent#457 completion report posts the result there
as an agent message. The open chat watches for it for up to 5 minutes,
since it has no history poll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes. Reviewed at 8b761e69e.

Dismiss (ent#748) is solid:

  • It is a fifth ending through the ent#611 sink, and only the CAS winner triggers side effects.
  • A lost race is a 200 no-op.
  • The wake text and the read-back are correct.
  • Undo really sends nothing until it lapses.

Discuss (ent#747) is mostly right. The blocker is the last commit.

Verified locally

  • test_ent747_748_* (SQLite only, not the PG arm): 46 pass.
  • The #2996 route census, turn-context and ent#467 guards: 113 pass.
  • New vitest specs plus the ratchets: 188 pass.
  • The F2 bug below: reproduced.

F1, high: 8b761e69e widens the audience of every answered Workspace ask (out of scope)

operator_resume_service.py:102-142 (_workspace_destination, used at :235) stamps the resume run with source_channel='portal', a chat id and the addressee. It does this for every ask the addressee answers, including background and chat-turn asks whose chat is Main, not only discussed asks. Three consumers act on that stamp:

  1. channel_completion_report posts the run's final reply verbatim into the client's chat. The run is still told "An operator answered…", so it writes for an operator, not a client.
  2. turn_audience.py:165 sends any file the run shares to the client's Files tab. Before this, nobody got it; it was owner-only.
  3. chat_execution_service.py:1069: delegated children inherit the destination.

Example: a scheduled run asks a client to approve a refund batch. The resumed run's summary, which names other customers, now lands in that client's Main, along with any CSV it shares.

No acceptance criterion needs this, and no test pins the audience or inheritance for an operator_response row. Suggested fix, either:

  • (a) split this commit, plus its askResultWatch poll, into its own PR with an owner ruling; or
  • (b) limit it to the discussion chat only (drop the _WORKSPACE_THREAD_KEY fallback), tell the run that its final reply goes to that person, and add tests for turn_audience and delegation inheritance.

F2, medium: Discuss turns any link-write failure into "409 This ask is already pending."

In client_portal/asks/service.py:556-569, a bare except Exception around set_operator_queue_discussion_link catches DB errors and json.loads failures too. It then falls into the lost-to-an-ending branch. I reproduced it by forcing the write to raise, which gave 409 already_resolved This ask is already pending.

Suggested fix:

  • Catch only the race exception class.
  • Return the 409 only when _status_of(item) != "pending".
  • Return a retryable 503 when the ask is still pending and unlinked.
  • Add a test for that path.

Low

  • F3: DISCUSSION_NOTICE (~:507) puts the agent-written title in curly quotes inside a system row, which replays as platform text on a cold turn. The per-turn line JSON-quotes the title for exactly this reason. Use the same treatment, or drop the title (the pinned tile shows it).
  • F4: :625 json.dumps(str(o))[:120] truncates after encoding, which can drop the closing quote or leave a dangling \. Use json.dumps(str(o)[:118]) instead.
  • F5:
    • The dismiss route doesn't refuse alerts, while discuss does.
    • The cancel_item CAS has no expires_at clause, so a dismiss can land after the deadline but before the sweep. Operator cancel already has this gap.
  • F7, tests: TestRoutes calls the handlers directly with a SimpleNamespace principal and a stubbed rate limiter. The criterion "an agent key is refused" is enforced by get_portal_principal, which these routes never exercise. Add one real TestClient request with an agent-scoped key (expect 403), and one with the limiter live (expect 429).

Nits

  • X-Total-Count only counts the discussed ask on page 1.
  • tables.py:1489 still lists answered|cancelled|expired as the disposition values.
  • dismissedLocally re-stamps ended_at on every poll.
  • Send as answer silently ignores a pending attachment or reply chip.
  • 500 is hard-coded to mirror max_length.

Acceptance criteria

  • ent#748: 6 of 8 met. Partial:
    • the admin half: an admin can only operator-cancel, which records cancelled;
    • the test criterion: store-level only, and no real-dependency refusal.
  • ent#747: 6 of 8 met. Partial:
    • the first message names the title only; body, options and raising context aren't seeded;
    • free text can't be an approval's answer (documented as deliberate, #2376).

Separately, there's a composer collision with #3168 ("Send as answer" sits between the reply chip and the composer and ignores the reply). The details are on my #3168 review; whichever PR lands second fixes it.

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

✅ Nightly unit-suite clean when this PR is merged into dev, all 3 seeds (head_sha: 76c6f9da7cd8fbbdeded4ab8b093e75a6291b7db).

@vybe

vybe commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

merge-train: not on today's train. It rides the next one once fixed.

@AndriiPasternak31's CHANGES_REQUESTED (09:08, reviewed at 8b761e69e, blocker on the last commit — the Discuss result-back-to-chat path) has no push after it; the head is still the 08:40 commit. The branch also conflicts with dev now. Labelled status-needs-fix so the next train skips it until you push; your push clears it.

@vybe vybe added the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

dolho and others added 2 commits October 5, 2026 09:36
…-discuss-dismiss

# Conflicts:
#	src/frontend/src/components/portal/PortalConversation.vue
…rite errors

F1: an answered ask's resume run reports into the ask's DISCUSSION chat
only. The attached-chat fallback (Main for a background ask) widened every
answered ask's audience to the client — verbatim final reply, shared files,
inherited by delegated children. The run is now told its reply goes to
the person. Store watch narrowed to match. Tests pin turn_audience and
delegation inheritance for the stamped run.

F2: the discussion-link write tolerates only SQLite's busy snapshot
(OperationalError). Any other failure, or a busy write with no racing
link while the ask is still pending, is a retryable 503 instead of
"409 This ask is already pending".

F3: the discussion's system notice no longer quotes the agent-written title.
F4: options are truncated before JSON encoding, keeping the closing quote.
F5: Dismiss refuses alerts (422), like Discuss and the card.
F7: real TestClient tests through get_portal_principal (agent key 403)
and the live in-process limiter (429).
Send as answer stands down while a reply chip or attachment is in the
composer (#3168 collision). tables.py disposition comment lists dismissed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Review follow-up (@AndriiPasternak31), in e7a788616 on top of a dev merge (cd4e48334, one import conflict in PortalConversation.vue with #3168).

F1 (high), option (b): _workspace_destination now stamps the destination only for the ask's discussion chat. The _WORKSPACE_THREAD_KEY fallback is gone, so a background or chat-turn ask answered from the queue stays owner-only. When the run does carry the destination, its framing tells it that the final reply goes to that person verbatim and that shared files reach their Files tab. answerAsk's askResultWatch watches discussion_chat_id only. New tests:

  • an undiscussed answer dispatches with no source_channel* and the operator framing;
  • turn_audience resolves the stamped run to the person;
  • a delegated child inherits the discussion chat;
  • a child's audience is NOBODY.

F2: only sqlalchemy.exc.OperationalError (SQLite busy snapshot) re-reads. Any other exception returns 503 discussion_unavailable. A write that leaves the ask pending and unlinked also returns a retryable 503. 409 now means the ask really ended. There are tests for all three paths.

F3: the system notice no longer contains the title. F4: options are truncated before encoding. F5: Dismiss refuses alerts (422 not_dismissable), matching Discuss and canDismiss. I left the cancel_item expires_at gap alone because operator cancel shares it; it's a separate fix.

F7: I added TestRoutesOverHttp, which uses a real TestClient through get_portal_principal. An agent-scoped key gets 403 on both routes and the row is untouched. With the in-process limiter live, request limit+1 gets 429 with Retry-After.

#3168 collision: "Send as answer" is disabled while a reply chip or attachment is in the composer, and says why. A mount spec covers this and fails without the fix.

Nits: I fixed the tables.py disposition comment. I didn't do the X-Total-Count, dismissedLocally re-stamp or the hard-coded 500.

Verified: test_ent747_748_* 57 pass. 24 related unit files (ent329/457/467/610/611/747, #2996 census, turn context/audience, completion report) gave 800 passed. Vitest: the discuss/dismiss, ask tiles, in-chat reply and new composer specs plus the ratchets, 97 passed. check_alembic_heads: 1 head.

🤖 Generated with Claude Code

@github-actions github-actions Bot removed the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 5, 2026
@dolho

dolho commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

/review — automated pre-landing review (at e7a788616a07cda1aa9b9e1f5bb85b3651a49ce1)

Branch: feature/ent747-748-ask-discuss-dismiss → dev (merge-base 482ad0742)
Files Changed: 33 (+2366/-50)
Scope: CLEAN. Since the prior review the _workspace_destination result-back path is limited to the discussion chat, which matches option (b) of the last review. No other out-of-scope surface was added.
Plan Completion (ent#747 + ent#748 acceptance criteria, 16 items): 12 done / 2 partial / 0 not done / 2 changed / 0 unverifiable

  • ent#748 partial: "an admin can dismiss". Admins still only have operator-cancel, which records cancelled. Impact: LOW.
  • ent#747 changed:
    • The first message is now a platform notice with no title, body or options. That is the F3 fix. The pinned tile and the per-turn line carry the ask instead.
    • The link uses a separate workspace_discussion_id key instead of reusing the ent#734 link, with the reason documented.
  • ent#747 partial: "a free-text reply answers an approval". This is deliberately out, because an approval must be decided by one of its options (bug: POST /api/operator-queue/{id}/respond accepts any string as an approval decision — no layer checks response ∈ options #2376). Impact: LOW.
  • Since the last review, ent#748's "an agent principal is refused" criterion moved to DONE (TestRoutesOverHttp).

Prior review findings (AndriiPasternak31, CHANGES_REQUESTED at 8b761e69e)

# Status at e7a788616 Evidence
F1 (high): resume run stamped with the portal destination for every answered ask ✅ Resolved operator_resume_service.py _workspace_destination now reads only context.get(_WORKSPACE_DISCUSSION_KEY). The _WORKSPACE_THREAD_KEY fallback is gone. _framed_message(..., to_person=bool(destination)) tells the run that its reply and its files reach the person. Mutation check: I re-added the fallback (or context.get("workspace_session_id")). Two tests went red: test_without_a_discussion_it_reports_into_no_chat and test_an_undiscussed_answer_is_dispatched_without_a_destination. TestResumeAudience pins turn_audience and delegated-child inheritance. But the feature-flow doc still describes the old, wider behaviour (I1).
F2 (medium): any link-write failure came back as 409 "already pending" ✅ Resolved client_portal/asks/service.py: except OperationalError re-reads the row; any other exception now returns 503 discussion_unavailable. If the row is still pending and unlinked after the write, that is also a 503. A 409 only fires when _status_of(item) != "pending". Mutation check: with the 8b761e69e service.py restored, the two new 503 tests fail.
F3 (low): agent-written title inside the system notice ✅ Resolved DISCUSSION_NOTICE no longer contains the title. The per-turn line JSON-quotes it: title = json.dumps(" ".join(...)[:200]). The pre-fix file fails test_discuss_opens_one_chat_titled_after_the_ask_and_seeded.
F4 (low): truncation after JSON encoding ✅ Resolved The code is now json.dumps(str(o)[:118]). The pre-fix file fails test_a_long_option_is_truncated_inside_its_quotes.
F5a (low): dismiss route accepted alerts ✅ Resolved dismiss_ask raises AskError(422, "not_dismissable", ...) for type == "alert". The pre-fix file fails test_an_alert_is_not_dismissable.
F5b (low): cancel_item CAS has no expires_at clause ⏸ Deferred (acknowledged) It is mitigated, not closed. dismiss_ask checks item.get("status") == "pending" and not _is_expired(item) before the write. A dismiss can still land in the gap between that read and the CAS. Operator cancel has the same gap.
F7 (tests): routes never exercised through their real dependencies ✅ Resolved TestRoutesOverHttp sends requests through a real TestClient, a real get_portal_principal and the live in-process limiter. An agent-scoped key gets 403 on both routes and the row is untouched. Request limit+1 gets 429 with Retry-After.
#3168 composer collision ✅ Resolved discussionAnswerBlocked = !!replyTo.value || attachments.value.length > 0 disables "Send as answer" and shows the reason. portalDiscussionAnswerComposer.mount.spec.js covers it.
Nits Partly done Done: the tables.py disposition comment. Still open: X-Total-Count, + len(extra) on page 1 only (list_asks_page), the dismissedLocally ended_at re-stamp on every poll, and the hard-coded 500 in PortalConversation.vue answerDiscussedAsk.

Execution coverage (Step 2.5)

changed symbol / test file executed by live consumer verdict
ask_service.dismiss / cancel_item(disposition=) TestDismiss (real DB) client_portal/asks/service.py::dismiss_ask ✅ executed
asks.service.dismiss_ask / discuss_ask + routes TestDismiss / TestDiscuss / TestRoutesOverHttp asks/router.py /{item_id}/dismiss, /{item_id}/discuss ✅ executed
db.set_discussion_link (CAS) TestDiscuss races + test_ent747_748_ask_writers_pg.py (PG arm not run here, SQLite pass) discuss_ask ✅ executed
_discussed_in / list_asks_page change test_the_discussion_chat_lists_its_ask_as_a_tile GET …/asks?chat_id= ✅ executed
discussion_context_line / _discussion_turn_line TestDiscussionTurnContext (called directly) turn_context.collect ← client_portal/service.py:3096 ✅ executed
_workspace_destination, _framed_message(to_person) TestResumeDeliversIntoTheChat, TestResumeAudience maybe_dispatch_resume ✅ executed (mutation red)
_framed_ending dismissed test_the_wake_turn_tells_the_agent_not_to_re_ask ending observer wake ✅ executed
Store dismissAsk / undoDismissAsk / commitDismissAsk / discussAsk / askResultWatch portalAskDiscussDismiss.mount.spec.js, portalChatAskTiles.spec.js (mounted, fake timers) PortalAsks.vue, PortalConversation.vue ✅ executed
test_the_prompt_copies_agree_on_the_dismissed_rule read_text() over prompt.md and platform_prompt_service.py the two vendored prompt copies 🛡 guard (parity)
portalChatAskTiles.spec.js readFileSync pre-existing import; the new cases are mounted — ✅ (new cases are not source-text)

Fix mutation: F1 → 2 red. F2/F3/F4/F5a (pre-fix service.py) → 5 red. No regression test is missing.

Local runs at this head:

  • test_ent747_748_ask_discuss_dismiss.py + test_ent747_748_ask_writers_pg.py: 62 passed (SQLite).
  • vitest (the 6 changed specs + raw-colour / loading-gate / source-text ratchets): 198 passed.

Critical Findings (block merge)

None.

Informational Findings (review required)

[I1] Documentation staleness: the feature flow still documents the F1 audience widening that this push removed (Confidence: 9/10)
File: docs/memory/feature-flows/operating-room.md:513
Evidence: "_workspace_destination now stamps the run with the Workspace destination — source_channel='portal', the ask's discussion chat else its attached chat (the raising turn's chat, or Main for a background ask)"
The code stamps the discussion chat only, per its own docstring: "The chat an ask is merely attached to … is not that consent, and its run stays owner-only".
Issue: the doc describes the exact behaviour the prior review blocked on. A later reader who "restores the documented behaviour" reopens F1.
Suggestion: rewrite the sentence to say discussion chat only, and to say an undiscussed answer stays owner-only with operator framing.

[I2] Documentation staleness: backend.md still says OSS registers no turn-context provider (Confidence: 8/10)
File: docs/memory/architecture/backend.md:141
Evidence: "OSS registers nothing → "", turns byte-identical."
asks/service.py now ends with a module-level _register_turn_context(), and the turn_context.py docstring was updated to "OSS registers one provider".
Suggestion: update the catalog line. This is the area file that owns services/**.

[I3] Enum completeness: a file-channel agent sees a dismissal as cancelled (Confidence: 7/10)
File: src/backend/services/operator_queue_service.py:1960-1963 (write-back, unchanged by this PR)
Evidence: req["status"] = term["status"]. For a dismissed row status is cancelled, so the agent's queue file gets cancelled.
Issue:

  • get_my_ask and the wake turn say dismissed, but an agent reading the queue-file fallback channel cannot tell a dismissal from an operator's cancel.
  • The prompt line "it ends dismissed" holds only for the MCP read-back.
  • The gate path also folds it, which is safe because it fails closed: skill_gate_service.py:838 .get(disposition, "cancelled").

Suggestion: either write disposition into the file entry for terminal flips, or say in the prompt copy that the file shows cancelled and get_my_ask shows dismissed.

[I4] Test gaps: the turn-context registry is now process state that two test files wipe (Confidence: 6/10)
Files: tests/unit/test_ent747_748_ask_discuss_dismiss.py:520, tests/unit/test_ent661_turn_context_wiring.py:27-29
Evidence: turn_context.clear_providers(), then service._register_turn_context(). The ent#661 autouse fixture calls clear_providers() on teardown.
Issue:

  • Production registers the discussion provider at import time, and the ent#661 fixture docstring describes the "OSS no-op path", which no longer exists.
  • After either file runs, the import-time registration is gone for the rest of the process.
  • No current test depends on collect() returning the discussion line, so nothing fails today. A future end-to-end turn test would pass or fail depending on test order, which is the class pytest-randomly surfaces.

Suggestion: snapshot and restore turn_context._providers in both fixtures instead of clearing them.

[I5] Prior nits still open (Confidence: 8/10)

  • list_asks_page: total=total + len(extra) only on offset == 0.
  • dismissedLocally stamps ended_at: new Date().toISOString() on every poll inside the Undo window.
  • if (text.length > 500) mirrors WorkspaceAskAnswer.response's max_length by hand.

None blocks; the author explicitly deferred them.

Clean Categories

  • SQL & data safety:
    • set_discussion_link is a parameterized SQLAlchemy update with CAS on status == "pending" and the exact stored context text.
    • cancel_item validates disposition in ("cancelled", "dismissed") and raises ValueError otherwise.
    • No schema change, so the "No migration" claim is correct.
  • Race conditions:
    • Racing Discuss clicks agree on one id: the CAS loser writes 0 rows and re-reads. A duplicate session insert falls back to get_portal_session.
    • Dismiss-vs-answer is resolved by the existing CAS and returns a 200 no-op.
    • Undo is withdrawn before the POST, using the committing token.
  • Auth boundaries:
    • Both routes check principal.is_person → 403 before the row is read, then the rate limit, then _owned_ask (uniform 404 covering addressee, roster and kind).
    • workspace_discussion_id was added to _PLATFORM_CONTEXT_KEYS, so agents cannot plant it (test_an_agent_cannot_pre_link_its_ask_to_a_chat).
    • The turn line is scoped by addressed_to_email=ctx.person_email and returns nothing for another person's chat or a room.
  • Credential exposure: none; log lines carry ids and email only, as existing portal logs do.
  • Enterprise disclosure (4.5): enterprise-docs-guard PATTERN over added docs/ lines finds no hit.
  • Prompt-injection surface: in the per-turn line, the agent's title and options are JSON-quoted. The person's answer is never included.
  • Frontend:
    • No v-html.
    • New controls use BaseButton, and no new raw palette classes were added (ratchet passes).
    • A refused Discuss or Dismiss shows the server's reason on the card.

Low confidence (appendix)

  • (5/10) ask_service.dismiss skips may_end(...). Every other ending door calls it ("the one rule every ending door shares"). For a gate approval, addressed_to_email comes from role_addressing.resolve(...).single, so in practice the addressee is in resolved_to, and a dismissal only ever denies (fail-closed). Calling may_end(current, actor, cancelling=True) would keep the rule in one place.
  • (5/10) discussion_context_line runs a context LIKE query on every 1:1 Workspace thread turn, including chats that discuss nothing. It is bounded by agent_name + addressed_to_email + limit=5, but it is a per-turn cost the seam did not have before.
  • (5/10) The _workspace_destination docstring says "The chat must be a live chat". get_portal_session does not filter archived_at, so an archived discussion chat still receives the completion report.
  • (4/10) discuss_ask on an ended ask whose discussion chat was deleted recreates it via create_portal_session, which contradicts "An ended ask continues its discussion but never opens one".

Summary

  • Critical: 0
  • Informational: 5. The main ones are I1 (the doc still describes the reverted audience widening) and I3 (a file-channel agent sees cancelled).
  • Scope: clean. F1, F2, F3, F4, F5a and F7 are resolved at this head, each with a regression test that fails when its fix is reverted. F5b is acknowledged as deferred.

Suggested learning / deferred debt: deferred debt for F5b. cancel_item (operator cancel and Dismiss) has no expires_at > now clause in its CAS. respond_to_item has one, so a cancel or dismiss can record over an expiry that has not been swept yet.

🤖 Generated with Claude Code

operating-room.md: an answered ask's result returns to its discussion
chat only; the attached chat is not a destination (F1), so an
undiscussed answer's run stays owner-only.
backend.md: turn_context now has one OSS provider, the discussed-ask line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Review follow-up at 76c6f9da7 (docs only, no code changes):

  • I1 — fixed. operating-room.md now says the result returns to the ask's discussion chat only, matching _workspace_destination's docstring. The attached chat (the raising turn's chat, or Main) is explicitly not a destination (F1), so an undiscussed answer's run stays owner-only.
  • I2 — fixed. backend.md's turn_context.py entry now says OSS registers one provider, the discussed-ask line (asks/service.py::_register_turn_context). Every other turn still composes byte-identically.
  • I3 — known limitation, no change. The docs only claim dismissed for the get_my_ask readback and the Workspace projection, never for the queue file. A file-channel agent still reads a dismissal as cancelled because the write-back copies status. This is safe because it fails closed. Writing disposition into the file is left for a follow-up.
  • I4 — not part of this pass.
  • I5 — deferred, as before. These stay open: the X-Total-Count/total only on offset == 0, the ended_at re-stamp on each poll in the Undo window, and the hard-coded 500 mirror of max_length.

🤖 Generated with Claude Code

@vybe

vybe commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

@AndriiPasternak31 could you re-review? It is on today's merge train, and your CHANGES_REQUESTED from 10-02 (at 8b761e69e) is the only thing keeping it out.

I validated head 76c6f9da7 independently, without relying on the author's table:

  • F1 (resume run stamped with the portal destination): operator_resume_service.py:124-167 now takes only the discussion chat. Restoring the attached-chat fallback turns test_without_a_discussion_it_reports_into_no_chat and test_an_undiscussed_answer_is_dispatched_without_a_destination red.
  • F2 (409 on a link-write failure): client_portal/asks/service.py:569-592 now returns 503 discussion_unavailable, and returns 409 only on a real loss to an ending. Restoring the 409 turns test_a_busy_write_with_no_racing_link_is_a_retryable_503 red.
  • F3, F4, F5a and F7 are resolved with regression tests. F5b, the missing expires_at clause in the cancel_item CAS, is deferred and acknowledged.
  • test_ent747_748_* gives 62/62 on the PR merged with dev. The changed specs and ratchets give 198/198 in vitest. CI is green.

If you approve, it merges with the train. If not, it waits for the next one.

trinity-ability pushed a commit that referenced this pull request Oct 5, 2026
- /m: a 'Something else' chip in the options group (reuses ops-option-btn,
  hidden on gate approvals) opens the form; the consequence line says none of
  the options will run; Send reads 'Send instruction' and stays disabled until
  the instruction is typed; maxlength 4000
- PortalAsks: the chip after the offered chips, hidden when the projection
  says decided_by_options (T8 ruling); typing with no pick arms it, placeholder
  and Send aria-label flip; Enter never sends the auto-armed state; submit()
  sends the armed option through the shared builder. Diff kept to the approval
  template, helpers beside pick(), the body of submit() and one import line
  (open PR #3181 edits other hunks)
- portalAsksTestidPrefix: the id inventory gains the chip's id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. Re-reviewed at 76c6f9da7. My 10-02 blocker is fixed, and I checked each fix by reverting it and running the tests.

Verified

  • F1: the resume run now gets a destination only for the ask's discussion chat. The run is told who reads its reply. Re-adding the workspace_session_id fallback turns test_without_a_discussion_it_reports_into_no_chat and test_an_undiscussed_answer_is_dispatched_without_a_destination red. Re-adding the store's || data.chat_id turns the store-watch spec red. TestResumeAudience covers the audience and inheritance cases I asked for.
  • F2: a 409 now means the ask really ended. A pending, unlinked ask returns a retryable 503. Turning that back into a 409 makes test_a_busy_write_with_no_racing_link_is_a_retryable_503 red.
  • F3, F4, F5a, the discuss rate limit and the #3168 reply-chip block: reverting each fix makes its new test red.
  • Runs: test_ent747_748_* 62/62. With 21 neighbouring suites (ent329/428/457/610/611/661/734, #2996 census, #2094, #224, m3): 717 passed. vitest: 6 changed specs plus the ratchets, 198/198.

Non-blocking follow-ups

  1. TestRoutesOverHttp::test_an_agent_scoped_key_is_refused only checks the 403 status. Deleting reject_agent_principal(user) from get_portal_principal leaves it green, because the stub owner has no user row and the request gets 403 "No email is associated with this account" instead. Please assert the detail, or seed the owner row. The dependency itself is pinned by the #2996/#2198 suites, so this is test precision, not a hole.
  2. The except OperationalError narrowing (F2) isn't pinned: except Exception passes all 62 tests. A caplog assertion on the WARNING would fix that.
  3. Please file one follow-up for the deferrals: F5b (no expires_at in the cancel_item CAS), I3 (file-channel agents see cancelled), I4 (clear_providers() wipes the import-time provider), and the three nits (X-Total-Count, the ended_at re-stamp, the hard-coded 500).
  4. The ask_service.py line in architecture/backend.md doesn't list dismiss yet, and the #3168 composer spec has no attachment case.
  5. On the issues, please record the two AC deviations: ent#747's first message is now a bare notice (the tile and the per-turn line carry the ask), and ent#748 has no admin dismiss. Both cross-tracker issues need a manual status-in-dev after merge.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

merge-train: batch validated on train/20261006-0833 (#3266)

@vybe
vybe merged commit 3abadfd into dev Oct 6, 2026
29 checks passed
vybe pushed a commit that referenced this pull request Oct 6, 2026
- /m: a 'Something else' chip in the options group (reuses ops-option-btn,
  hidden on gate approvals) opens the form; the consequence line says none of
  the options will run; Send reads 'Send instruction' and stays disabled until
  the instruction is typed; maxlength 4000
- PortalAsks: the chip after the offered chips, hidden when the projection
  says decided_by_options (T8 ruling); typing with no pick arms it, placeholder
  and Send aria-label flip; Enter never sends the auto-armed state; submit()
  sends the armed option through the shared builder. Diff kept to the approval
  template, helpers beside pick(), the body of submit() and one import line
  (open PR #3181 edits other hunks)
- portalAsksTestidPrefix: the id inventory gains the chip's id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vybe added a commit that referenced this pull request Oct 6, 2026
…#3250)

* feat(operator-queue): backend sink accepts the reserved (something else) answer (#3242)

The #2376 sink now handles SOMETHING_ELSE = "(something else)" before
membership: accepted on any approval with an instruction in response_text,
refused by name otherwise (instruction_required, reserved_value), and refused
on platform-minted / gate approvals (not_off_menu). Every other unoffered
string stays refused. validate_response_choice takes response_text
keyword-only and required.

- ask_service: gate refusal; ask_operator refuses the literal as an option
- both writers map ReservedAnswerError to a named 422
- OperatorResponse.response_text bounded at 4000
- Workspace projection: decided_by_options boolean (T8 ruling)
- recent answers render "Something else: <instruction>"
- resume frame: platform sentence above the data fence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): contract text and MCP tools name (something else) (#3242)

- platform prompt (both authored copies) + agent guide: the approval bullet
  and the queue-file write-back say what the reserved value means and that
  the instruction is in response_text; never list it as an option
- test_1402 SENTINELS += "(something else)"
- MCP: types.ts exports SOMETHING_ELSE; ask_operator, get_my_ask and
  respond_to_operator_queue descriptions name it; the respond tool keeps
  `error` and adds the backend's {status, code, message, offered_options?}
  (T5, additive)
- py <-> ts parity test for the literal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): desktop Something else chip (#3242)

- utils/operatorQueue.js: SOMETHING_ELSE (mirrored, parity-tested),
  offeredChips (drops the literal and the size-cap marker), decisionLabel,
  decidedByOptions; buildQueueResponse needs an instruction with the literal
- QueueCard + QueueItemDetail: chips from offeredChips, a 'Something else'
  chip outside the v-for (hidden on gate approvals); typing with no pick arms
  it, the label and Send copy flip; Enter sends only after an explicit pick;
  maxlength 4000; Send rule is the shared builder
- respondToItem returns true/false; the detail panel clears only on success
- ResolvedCard + detail resolved view render 'Something else', never the literal

Raw-colour counts unchanged (QueueCard 28/59, QueueItemDetail 26/55).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): Something else on /m and the Workspace (#3242)

- /m: a 'Something else' chip in the options group (reuses ops-option-btn,
  hidden on gate approvals) opens the form; the consequence line says none of
  the options will run; Send reads 'Send instruction' and stays disabled until
  the instruction is typed; maxlength 4000
- PortalAsks: the chip after the offered chips, hidden when the projection
  says decided_by_options (T8 ruling); typing with no pick arms it, placeholder
  and Send aria-label flip; Enter never sends the auto-armed state; submit()
  sends the armed option through the shared builder. Diff kept to the approval
  template, helpers beside pick(), the body of submit() and one import line
  (open PR #3181 edits other hunks)
- portalAsksTestidPrefix: the id inventory gains the chip's id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(operator-queue): the something-else answer on approvals (#3242)

Document what #3242 built: the reserved (something else) decision an
approval accepts besides its own options, with the instruction in
response_text; the named 422s (instruction_required, reserved_value,
not_off_menu, invalid_options at raise); the Something else chip on the
desktop card and detail, /m and the Workspace, hidden on gate approvals
on every surface (decided_by_options on the Workspace projection); the
resume frame and contract text. Corrects every doc that still said an
approval's answer must be one of its options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): drop the reversed Send-predicate source pin and dead optionsOf import (C1, I1) (#3242)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): pin readback, marker mirror, guide literal, near-miss literals; public decided_by_options; learnings (I2 I4 I5 I6 I7 I12) (#3242)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): carry the sink's decided_by_options on the operator projections; gate- is only the fallback (I3) (#3242)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vybe added a commit that referenced this pull request Oct 6, 2026
…or contract, option and title limits (#3243) (#3253)

* feat(operator-queue): backend sink accepts the reserved (something else) answer (#3242)

The #2376 sink now handles SOMETHING_ELSE = "(something else)" before
membership: accepted on any approval with an instruction in response_text,
refused by name otherwise (instruction_required, reserved_value), and refused
on platform-minted / gate approvals (not_off_menu). Every other unoffered
string stays refused. validate_response_choice takes response_text
keyword-only and required.

- ask_service: gate refusal; ask_operator refuses the literal as an option
- both writers map ReservedAnswerError to a named 422
- OperatorResponse.response_text bounded at 4000
- Workspace projection: decided_by_options boolean (T8 ruling)
- recent answers render "Something else: <instruction>"
- resume frame: platform sentence above the data fence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): contract text and MCP tools name (something else) (#3242)

- platform prompt (both authored copies) + agent guide: the approval bullet
  and the queue-file write-back say what the reserved value means and that
  the instruction is in response_text; never list it as an option
- test_1402 SENTINELS += "(something else)"
- MCP: types.ts exports SOMETHING_ELSE; ask_operator, get_my_ask and
  respond_to_operator_queue descriptions name it; the respond tool keeps
  `error` and adds the backend's {status, code, message, offered_options?}
  (T5, additive)
- py <-> ts parity test for the literal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): desktop Something else chip (#3242)

- utils/operatorQueue.js: SOMETHING_ELSE (mirrored, parity-tested),
  offeredChips (drops the literal and the size-cap marker), decisionLabel,
  decidedByOptions; buildQueueResponse needs an instruction with the literal
- QueueCard + QueueItemDetail: chips from offeredChips, a 'Something else'
  chip outside the v-for (hidden on gate approvals); typing with no pick arms
  it, the label and Send copy flip; Enter sends only after an explicit pick;
  maxlength 4000; Send rule is the shared builder
- respondToItem returns true/false; the detail panel clears only on success
- ResolvedCard + detail resolved view render 'Something else', never the literal

Raw-colour counts unchanged (QueueCard 28/59, QueueItemDetail 26/55).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): Something else on /m and the Workspace (#3242)

- /m: a 'Something else' chip in the options group (reuses ops-option-btn,
  hidden on gate approvals) opens the form; the consequence line says none of
  the options will run; Send reads 'Send instruction' and stays disabled until
  the instruction is typed; maxlength 4000
- PortalAsks: the chip after the offered chips, hidden when the projection
  says decided_by_options (T8 ruling); typing with no pick arms it, placeholder
  and Send aria-label flip; Enter never sends the auto-armed state; submit()
  sends the armed option through the shared builder. Diff kept to the approval
  template, helpers beside pick(), the body of submit() and one import line
  (open PR #3181 edits other hunks)
- portalAsksTestidPrefix: the id inventory gains the chip's id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(operator-queue): the something-else answer on approvals (#3242)

Document what #3242 built: the reserved (something else) decision an
approval accepts besides its own options, with the instruction in
response_text; the named 422s (instruction_required, reserved_value,
not_off_menu, invalid_options at raise); the Something else chip on the
desktop card and detail, /m and the Workspace, hidden on gate approvals
on every surface (decided_by_options on the Workspace projection); the
resume frame and contract text. Corrects every doc that still said an
approval's answer must be one of its options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): drop the reversed Send-predicate source pin and dead optionsOf import (C1, I1) (#3242)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): pin readback, marker mirror, guide literal, near-miss literals; public decided_by_options; learnings (I2 I4 I5 I6 I7 I12) (#3242)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): carry the sink's decided_by_options on the operator projections; gate- is only the fallback (I3) (#3242)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): cap option count/length, agent titles and chip lookalikes on the native raise (#3243)

One pure predicate in operator_queue_choices (shared with the file path in the
next checkpoint): at most 5 options, the reserved (something else) never
counted; at most 60 characters per option; an option reading as the chip
("Something else", any case, parenthesised or not) refused as invalid_options.
Agent raises also get a hard 120-character title (title_too_long). Checked
after the replay lookup, so a pre-cap ask's retry still replays, and before
the rate cap, so a refusal spends no token. Env-overridable, floored at load
(2 options / 16 chars / 40-char title).

Mutation: with the _refuse_over_caps call removed, 9 tests in
test_3243_atomic_asks.py go red (TestNativeRaise refusals).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): hold over-cap queue-file entries and name them on the marker (#3243)

A NEW file entry over the option caps (or a chip lookalike) is held as
invalid_options, an over-long title as invalid_title — the same predicate as
the native raise, after the id check and before the depth/rate caps, so no
rate token is spent. The ids (<=10) and the limits ride on
platform.ingestion beside `reason`, so a self-clearing queue_full cannot hide
them; _marker_differs now compares the key set so a fixed entry's id drops
off. Cap holds never increment `held` (no "runaway or compromised agent"
flood alert). Rows already ingested are never re-judged.

Also replaces the CP1a env test's module reload (it handed other suites a
stale OperatorQueueSyncService) with a test of the loader itself.

Mutation: with the hold disabled, 4 TestFileHold tests go red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): teach atomic asks in the ask_operator contract and the prompt (#3243)

ask_operator's description carries five authoring rules (one decision per
ask; a one-glance title, at most 120; options name the choice only, with one
bad-then-good example; at most 5 options of at most 60 characters, aim for
under 40; context is labelled facts for people), the chip-lookalike rule, and
the three new refusal codes with what to do about each. Field descriptions
for title/question/options/context/proposal carry the per-field rule.

The platform prompt and prompt.md gain a three-line "Write atomic asks"
paragraph pointing at the tool, and the queue-file paragraph names the
invalid_options / invalid_title holds. Agent guide: one pointer sentence.
Sentinels: "One decision per ask", "invalid_options". A pytest pins the
description and prompt to the phrases built from the Python caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(operator-queue): document the atomic-ask caps and their env knobs (#3243)

.env.example and both compose files gain OPERATOR_QUEUE_MAX_OPTIONS (5),
OPERATOR_QUEUE_OPTION_MAX_CHARS (60) and OPERATOR_QUEUE_ASK_TITLE_MAX_CHARS
(120). security.md §26, the operating-room flow (step + revision row) and the
api-endpoints catalog name the new codes, where they run, and the file hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): forward the three #3243 caps on the hosted compose so parity is green (#3243)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): quote cap defaults, name the agent title limit first, fold NFKC/zero-width chip lookalikes, register the test (#3243)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(operator-queue): describe the #3243 cap holds in the stale ingestion docs and add the §26.7 authoring-caps bullet (#3243)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): ask_operator fits Claude Code's 2,048-character description cap (#3243)

Claude Code shows a model only the first 2,048 characters of a tool
description (#3234); ask_operator was 2,676 at 3c14c23, so its last rules
(the refusal codes and their remedies) were invisible to every agent.

The description is now 1,710 characters, ordered by what a model acts on
first: what the tool does and the fire-and-park contract, then the five
atomic-ask rules, then the reserved (something else) rule. Field detail
moved into the parameter descriptions, which are not cut: the receipt
fields, differs and idempotency (request_id); title_too_long and its fix
(title); the bad-then-good option example, options_required,
too_many_options / option_too_long / invalid_options with their fixes
(options); field_too_large (question, context, proposal); role_unassigned
(to); how an ask ends (expires_at); reask_requires_link
(supersedes_expired).

Tests: the published descriptions of ask_operator, get_my_ask and
respond_to_operator_queue are <= 2,048 over a real listTools, and every
moved phrase is published on its field; the unit test keeps ask_operator
<= 1,800 for headroom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants