From 6a43bd59bf588dca219a0661c15d1e6c8ae5a814 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 22:22:17 -0400 Subject: [PATCH 1/4] Prepare shared message submit modes with honest unsupported steering --- scripts/codex_von_inbox.py | 22 +++- src/backend/server/routes/message_routes.py | 30 ++++++ src/backend/services/message_service.py | 40 ++++++- .../js/components/chatSteeringControls.js | 21 ++-- .../static/js/components/messagePanel.js | 23 +++- .../js/components/messageSubmitControls.js | 74 +++++++++++++ .../static/js/components/submitMode.js | 45 ++++++++ .../web/von_interface/static/styles.css | 14 ++- tests/backend/test_codex_von_inbox.py | 32 ++++++ .../test_codex_von_inbox_canonical_replay.py | 7 ++ tests/backend/test_message_routes.py | 45 ++++++++ tests/backend/test_message_service.py | 61 +++++++++++ tests/browser/messageSubmitMode.cjs | 100 ++++++++++++++++++ .../frontend/messagePanelSendFailure.test.js | 11 +- tests/frontend/messageSubmitControls.test.js | 71 +++++++++++++ 15 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 src/frontend/web/von_interface/static/js/components/messageSubmitControls.js create mode 100644 src/frontend/web/von_interface/static/js/components/submitMode.js create mode 100644 tests/browser/messageSubmitMode.cjs create mode 100644 tests/frontend/messageSubmitControls.test.js diff --git a/scripts/codex_von_inbox.py b/scripts/codex_von_inbox.py index fbbb58108..067c064b5 100644 --- a/scripts/codex_von_inbox.py +++ b/scripts/codex_von_inbox.py @@ -352,6 +352,23 @@ def prepare_attachment_inputs(config, context, run): def launch(config, state, lock_fd): + # A retained/externally-created steering message cannot be interpreted as a + # fresh task after its intended turn has ended. This exec route has no live + # turn transport yet. Report non-delivery without asking a model to execute it. + if state["context"]["message"].get("submit_mode", "queue") != "queue": + state["result"] = { + "answer": ( + "This message was not applied: active steering is unavailable on " + "the installed DGX inbox route. The original message is retained. " + "Choose Queue explicitly to request separate work." + ), + "task_id": "", + "action": "reply", + "deployment_requested": False, + "new_task": None, + } + state["phase"] = "reporting" + return run = Path(state["run_dir"]) run.mkdir(parents=True, exist_ok=True) settings = resolve_execution_settings(config, {}) @@ -420,7 +437,10 @@ def authorise_source(config, api, message): api.messages.project_direct_message(current), config ): raise PermissionError("Inbound message no longer available to this worker") - if api.messages.project_direct_message(current)["content"] != message["content"]: + projection = api.messages.project_direct_message(current) + if projection["content"] != message["content"] or projection.get( + "submit_mode", "queue" + ) != message.get("submit_mode", "queue"): raise PermissionError("Inbound message changed during reply") allowed, _ = api.messages.authorise_direct_message_participants( sender_id=config["agent_id"], diff --git a/src/backend/server/routes/message_routes.py b/src/backend/server/routes/message_routes.py index 239731865..94cf9fc94 100644 --- a/src/backend/server/routes/message_routes.py +++ b/src/backend/server/routes/message_routes.py @@ -13,6 +13,7 @@ from ...services.message_service import ( DirectMessageIdempotencyConflict, + DirectMessageSteeringUnavailable, authorise_direct_message_participants, create_message, create_message_idempotently, @@ -27,6 +28,7 @@ normalise_direct_message_delivery_idempotency_key, sanitise_direct_message_delivery_metadata, search_messages, + validate_direct_message_submit_mode, ) from ...services.paper_recommendation_review_service import ( build_message_linked_paper_recommendation_review, @@ -467,6 +469,33 @@ def send_message() -> ResponseReturnValue: metadata_payload = sanitise_direct_message_delivery_metadata( data.get("metadata") if isinstance(data.get("metadata"), dict) else None ) + try: + mode = data.get("submit_mode", metadata_payload.get("submit_mode", "queue")) + if ( + "submit_mode" in metadata_payload + and metadata_payload["submit_mode"] != mode + ): + raise ValueError("Conflicting submit modes") + if any( + key in data or key in metadata_payload + for key in ("submit_target", "submit_intent", "steering_target") + ): + raise ValueError( + "Active-turn targets are not supported by this message route" + ) + metadata_payload["submit_mode"] = validate_direct_message_submit_mode(mode) + except DirectMessageSteeringUnavailable as exc: + return ( + jsonify( + error=str(exc), + error_code="message_steering_unavailable", + effect_status="not_applied", + retryable=False, + ), + 409, + ) + except ValueError as exc: + return jsonify(error=str(exc), error_code="invalid_submit_intent"), 400 from ...services.message_attachment_service import authorise_message_attachments # Descriptors and audience are server-derived, never trusted metadata. @@ -560,6 +589,7 @@ def send_message() -> ResponseReturnValue: "reused": reused, "idempotent_replay": reused, "delivery_status": delivery_status, + "submit_mode": metadata_payload["submit_mode"], "message_id": message.get("concept_id"), "conversation": conversation, "sent_at": message.get("concept_data", {}).get("sent_at"), diff --git a/src/backend/services/message_service.py b/src/backend/services/message_service.py index 05ed900a4..2f7dea5ce 100644 --- a/src/backend/services/message_service.py +++ b/src/backend/services/message_service.py @@ -69,6 +69,30 @@ class DirectMessageIdempotencyConflict(ValueError): """The same scoped idempotency key names a different message intent.""" +class DirectMessageSteeringUnavailable(ValueError): + """No receiving-controller active-turn binding is available on this route.""" + + +def validate_direct_message_submit_mode(mode: Any = "queue") -> str: + if mode not in ("queue", "steer"): + raise ValueError("submit_mode must be queue or steer") + if mode == "steer": + raise DirectMessageSteeringUnavailable( + "Active steering is unavailable on this route. Retain the draft or " + "explicitly choose Queue for separate work." + ) + return mode + + +def _validate_submit_metadata(metadata: Optional[Dict[str, Any]]) -> None: + metadata = metadata if isinstance(metadata, dict) else {} + validate_direct_message_submit_mode(metadata.get("submit_mode", "queue")) + if any( + key in metadata for key in ("submit_target", "submit_intent", "steering_target") + ): + raise ValueError("Active-turn targets are not supported by this message route") + + @dataclass(frozen=True) class DirectMessageDeliveryIdentity: """Opaque hashes used to reconcile one actor-scoped delivery request.""" @@ -197,6 +221,7 @@ def build_direct_message_delivery_identity( fields is therefore conflicting key reuse rather than a second delivery. """ + _validate_submit_metadata(metadata) normalised_key = normalise_direct_message_delivery_idempotency_key( delivery_idempotency_key ) @@ -246,7 +271,13 @@ def build_direct_message_delivery_identity( reply_to_id, field_name="reply_to_id", ), - "metadata": _material_delivery_metadata(metadata), + # Queue was the implicit legacy intent. Keep a retained pre-upgrade + # UI retry on the same identity when it now spells that mode out. + "metadata": { + key: value + for key, value in _material_delivery_metadata(metadata).items() + if not (key == "submit_mode" and value == "queue") + }, } ) delivery_fingerprint = _canonical_json_sha256( @@ -334,6 +365,8 @@ def relationship_values(predicate: str) -> List[str]: from .message_attachment_service import message_attachment_descriptors attachments = message_attachment_descriptors(message_doc) + metadata = concept_data.get("metadata") + metadata = metadata if isinstance(metadata, dict) else {} return { **({"attachments": attachments} if attachments else {}), "message_id": str(message_doc.get("concept_id") or ""), @@ -356,6 +389,9 @@ def relationship_values(predicate: str) -> List[str]: ), "organisation_concept_id": concept_data.get("organisation_concept_id"), "attribution": concept_data.get("attribution"), + # Unknown/unsupported recorded modes must reach controllers unchanged; + # dropping them would turn old steering intents into new queued work. + "submit_mode": metadata.get("submit_mode", "queue"), } @@ -395,6 +431,7 @@ def create_message( Returns: The created message document. """ + _validate_submit_metadata(metadata) if not sender_id: raise ValueError("sender_id is required") if not recipient_ids: @@ -442,6 +479,7 @@ def create_message( metadata_payload["attachments"] = authorise_message_attachments( [a["concept_id"] for a in metadata_payload["attachments"]], sender_id ) + metadata_payload.setdefault("submit_mode", "queue") attribution = metadata_payload.get("attribution") if not isinstance(attribution, str) or not attribution.strip(): attribution = f"Sent by Von on behalf of {sender_id}" diff --git a/src/frontend/web/von_interface/static/js/components/chatSteeringControls.js b/src/frontend/web/von_interface/static/js/components/chatSteeringControls.js index d4e21ae21..a9ae38f27 100644 --- a/src/frontend/web/von_interface/static/js/components/chatSteeringControls.js +++ b/src/frontend/web/von_interface/static/js/components/chatSteeringControls.js @@ -1,3 +1,5 @@ +import { getSubmitMode, setSubmitMode, selectedSubmitMode, presentSubmitMode, subscribeSubmitMode } from './submitMode.js'; + /** Steering is bound to the displayed actor/session and exact active attempt. */ export function createChatSteeringControls({ sendButton, request, getDraft, clearDraft, createId, onQueue, onModeChange = () => {} }) { const button = document.createElement('button'); @@ -31,11 +33,9 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea actions.prepend(modeLabel, help, button, queueButton); let actorKey; let defaultMode = 'queue'; - const modes = new Map(); modeSelect.onchange = () => { defaultMode = modeSelect.value === 'steer' ? 'steer' : 'queue'; - modes.set(actorKey, defaultMode); - try { if (actorKey) localStorage.setItem(`von:chat-submit-mode:${actorKey}`, defaultMode); } catch (_) { /* In-memory preference remains usable. */ } + setSubmitMode(actorKey, defaultMode); onModeChange(); }; const activity = document.createElement('details'); @@ -210,13 +210,16 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea } } button.onclick = submit; + const unsubscribe = subscribeSubmitMode(() => { + defaultMode = getSubmitMode(actorKey); + render(); + onModeChange(); + }); return { update(options) { if (actorKey !== options.actorKey) { actorKey = options.actorKey; - let stored; - try { if (actorKey) stored = localStorage.getItem(`von:chat-submit-mode:${actorKey}`); } catch (_) { /* Storage may be disabled. */ } - defaultMode = modes.get(actorKey) || (stored === 'steer' ? 'steer' : 'queue'); + defaultMode = getSubmitMode(actorKey); } const changed = scopeKey !== options.scopeKey; const targetChanged = current.target?.queueId !== options.target?.queueId; @@ -240,7 +243,7 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea // Only user composer activation calls this; background queue dispatch is unchanged. activate(event = {}) { if (!current.busy) return false; - const mode = event.shiftKey ? (defaultMode === 'queue' ? 'steer' : 'queue') : defaultMode; + const mode = selectedSubmitMode(actorKey, event); if (mode !== 'steer') return false; if (!current.target || current.disabled) { notice = 'Steering is unavailable for this draft or turn. Draft retained; choose Queue this message in More actions to send separate work.'; @@ -250,7 +253,7 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea }, present() { const mode = current.busy ? defaultMode : 'send'; - sendButton.dataset.submitMode = mode; + presentSubmitMode(sendButton, mode); if (!current.busy || current.queueDisabled) return; const label = mode === 'steer' ? 'Guide active turn' : 'Queue message'; sendButton.setAttribute('aria-label', label); @@ -259,6 +262,6 @@ export function createChatSteeringControls({ sendButton, request, getDraft, clea sendButton.title = `${label}. Shift-click for ${mode === 'steer' ? 'Queue' : 'Steering'} once. Change the default or choose either action in More actions.`; }, isSending() { return sending; }, - dispose() { generation += 1; clearTimeout(timer); clearTimeout(toastTimer); composerObserver?.disconnect(); window.removeEventListener('resize', positionToast); window.visualViewport?.removeEventListener('resize', positionToast); button.remove(); activity.remove(); toast.remove(); modeLabel.remove(); help.remove(); queueButton.remove(); } + dispose() { unsubscribe(); generation += 1; clearTimeout(timer); clearTimeout(toastTimer); composerObserver?.disconnect(); window.removeEventListener('resize', positionToast); window.visualViewport?.removeEventListener('resize', positionToast); button.remove(); activity.remove(); toast.remove(); modeLabel.remove(); help.remove(); queueButton.remove(); } }; } diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js index 9d8cce407..f32d3dbdf 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -1,3 +1,4 @@ +import { createMessageSubmitControls } from './messageSubmitControls.js'; import { initialiseConversationActions } from './conversationActions.js'; import { createLatestMessageButton, setNavigationVisible, focusConversationTarget } from './conversationNavigation.js'; import { bindAttachmentComposer, imageItems, imagesBlocked, renderImageAttachments, removeSentAttachments } from '../utils/conversationImages.js'; @@ -49,6 +50,8 @@ let _isLoading = false; let _unreadCount = 0; let _replySendFailureState = null; let _newMessageSendFailureState = null; +let _replySubmitControls; +let _newSubmitControls; let _replySendPending = false; let _newMessageSendPending = false; let _replyDeliveryAttempt = null; @@ -68,6 +71,8 @@ function exchangeScope(row = _exchange) { } export function resetMessagePanelContext() { + _replySubmitControls?.dispose(); + _newSubmitControls?.dispose(); const input = _messagesContainer?.querySelector('#messageInput'); if (_exchange && input) _exchangeDrafts.set(exchangeScope(), input.value); _exchangeGeneration += 1; @@ -486,6 +491,15 @@ function renderMessagesTabContent() { _replySendFailureState = null; _newMessageSendFailureState = null; + _replySubmitControls?.dispose(); + _newSubmitControls?.dispose(); + const submitControls = (button, onSubmit) => createMessageSubmitControls({ + button, getActor: readSessionActorConceptId, onSubmit, + onUnavailable: text => showToast(text, 'error') + }); + _replySubmitControls = submitControls(_messagesContainer.querySelector('#sendMessageBtn'), handleSendReply); + _newSubmitControls = submitControls(_messagesContainer.querySelector('#sendNewMessage'), handleSendNewMessage); + // Attach event listeners attachMessagesEventListeners(); initialiseConversationActions(_messagesContainer); @@ -520,7 +534,7 @@ function attachMessagesEventListeners() { // Send message button const sendBtn = _messagesContainer.querySelector('#sendMessageBtn'); if (sendBtn) { - sendBtn.addEventListener('click', handleSendReply); + sendBtn.addEventListener('click', event => _replySubmitControls.activate(event)); } // Message input - send on Enter (Shift+Enter for newline) @@ -529,7 +543,7 @@ function attachMessagesEventListeners() { msgInput.addEventListener('keydown', (e) => { if (shouldSubmitComposerKey(e)) { e.preventDefault(); - handleSendReply(); + _replySubmitControls.activate(e); } }); msgInput.addEventListener('input', syncVisibleReplyDeliveryAttempt); @@ -559,7 +573,7 @@ function attachMessagesEventListeners() { const sendNewBtn = _messagesContainer.querySelector('#sendNewMessage'); if (sendNewBtn) { - sendNewBtn.addEventListener('click', handleSendNewMessage); + sendNewBtn.addEventListener('click', event => _newSubmitControls.activate(event)); } const newMessageRecoverySelect = _messagesContainer.querySelector('#newMessageRecoverySelect'); @@ -1238,7 +1252,7 @@ function renderComposePendingUi(scope) { if (composer) composer.dataset.hasAttachments = String(imageItems(attachmentScope(scope)).length > 0); } button.disabled = pending || imagesBlocked(attachmentScope(scope)) || (scope === COMPOSE_SCOPE_REPLY && _exchange?.other_participant_ids?.length > 1); - button.textContent = pending ? 'Sending…' : 'Send'; + (scope === COMPOSE_SCOPE_REPLY ? _replySubmitControls : _newSubmitControls)?.update(pending); button.setAttribute('aria-busy', pending ? 'true' : 'false'); if (scope === COMPOSE_SCOPE_NEW_MESSAGE) { @@ -2196,6 +2210,7 @@ function buildSendPayload({ return { recipient_ids: recipientIds, + submit_mode: 'queue', content, ...(attachmentIds.length ? {attachment_ids: attachmentIds} : {}), delivery_idempotency_key: deliveryIdempotencyKey, diff --git a/src/frontend/web/von_interface/static/js/components/messageSubmitControls.js b/src/frontend/web/von_interface/static/js/components/messageSubmitControls.js new file mode 100644 index 000000000..9173a0c6d --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/messageSubmitControls.js @@ -0,0 +1,74 @@ +import { getSubmitMode, setSubmitMode, selectedSubmitMode, presentSubmitMode, subscribeSubmitMode, submitArrowMarkup } from './submitMode.js'; + +// This route currently has no verified live-turn transport. Never relabel queue +// acceptance as steering; retain the draft so the sender can choose separate work. +export function createMessageSubmitControls({ button, getActor, onSubmit, onUnavailable }) { + button.innerHTML = submitArrowMarkup; + const menu = document.createElement('details'); + menu.className = 'message-submit-actions conversation-actions'; + const summary = document.createElement('summary'); + summary.textContent = 'Submit options'; + const panel = document.createElement('div'); + panel.className = 'conversation-actions-panel'; + const label = document.createElement('label'); + label.textContent = 'Default action (shared with chat in this browser): '; + const select = document.createElement('select'); + select.add(new Option('Queue', 'queue')); + select.add(new Option('Steering', 'steer')); + label.append(select); + const help = document.createElement('p'); + help.id = `${button.id}ModeHelp`; + help.textContent = 'Queue sends a separate message. Active coding-agent steering is not available on this route yet. Choosing Steering retains your draft. Shift-click the arrow for the opposite action once; Shift+Enter inserts a newline. Choose either action here with keyboard or touch.'; + select.setAttribute('aria-describedby', help.id); + button.setAttribute('aria-describedby', help.id); + const actions = []; + for (const [mode, text] of [['queue', 'Queue this message'], ['steer', 'Steer (unavailable)']]) { + const action = document.createElement('button'); + action.type = 'button'; + action.textContent = text; + action.onclick = () => activate({}, mode); + panel.append(action); + actions.push(action); + } + panel.prepend(label, help); + menu.append(summary, panel); + button.parentElement.after(menu); + const status = document.createElement('span'); + status.className = 'sr-only'; + status.setAttribute('role', 'status'); + menu.after(status); + select.onchange = () => setSubmitMode(getActor(), select.value); + let pending = false; + function update(isPending = pending) { + pending = isPending; + const mode = getSubmitMode(getActor()); + select.value = mode; + select.disabled = pending; + for (const action of actions) action.disabled = button.disabled; + const description = mode === 'steer' ? 'Steering unavailable; draft will be retained' : 'Submit separate message'; + presentSubmitMode(button, mode, { + label: pending ? 'Submitting message' : description, + help: `${description}. Shift-click for the opposite action once. Submit options contains both actions and the shared default.`, + pending + }); + } + function activate(event = {}, explicitMode) { + if (pending || button.disabled) return; + const mode = explicitMode || selectedSubmitMode(getActor(), event); + if (mode === 'steer') { + status.textContent = 'Active steering is unavailable on this route. Draft and attachments retained; choose Queue this message in Submit options for separate work.'; + onUnavailable(status.textContent); + return; + } + status.textContent = ''; + menu.open = false; + button.focus(); + onSubmit(); + } + menu.addEventListener('keydown', event => { + if (event.key === 'Escape') { event.preventDefault(); menu.open = false; summary.focus(); } + }); + const unsubscribe = subscribeSubmitMode(() => update()); + update(); + return { activate, update, dispose() { unsubscribe(); menu.remove(); status.remove(); } }; +} diff --git a/src/frontend/web/von_interface/static/js/components/submitMode.js b/src/frontend/web/von_interface/static/js/components/submitMode.js new file mode 100644 index 000000000..a5028aeb8 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/submitMode.js @@ -0,0 +1,45 @@ +// Shared browser preference and presentation; delivery remains owned by each route. +const fallbackModes = new Map(); +const eventName = 'von:submit-mode-change'; + +export function getSubmitMode(actorKey) { + if (!actorKey) return 'queue'; + try { return localStorage.getItem(`von:chat-submit-mode:${actorKey}`) === 'steer' ? 'steer' : 'queue'; } + catch (_) { return fallbackModes.get(actorKey) || 'queue'; } +} + +export function setSubmitMode(actorKey, value) { + const mode = value === 'steer' ? 'steer' : 'queue'; + if (!actorKey) return; + fallbackModes.set(actorKey, mode); + try { localStorage.setItem(`von:chat-submit-mode:${actorKey}`, mode); } catch (_) { /* Keep the in-memory preference. */ } + window.dispatchEvent(new CustomEvent(eventName, { detail: { actorKey } })); +} + +export function subscribeSubmitMode(listener) { + window.addEventListener(eventName, listener); + window.addEventListener('storage', listener); + return () => { + window.removeEventListener(eventName, listener); + window.removeEventListener('storage', listener); + }; +} + +export function selectedSubmitMode(actorKey, event = {}) { + const mode = getSubmitMode(actorKey); + return event.shiftKey ? (mode === 'queue' ? 'steer' : 'queue') : mode; +} + +export function presentSubmitMode(button, mode, { label, help, pending } = {}) { + button.classList.add('composer-submit-arrow'); + button.dataset.submitMode = mode; + if (label) { + button.setAttribute('aria-label', label); + const text = button.querySelector('.button-label'); + if (text) text.textContent = label; + } + if (help) button.title = help; + if (pending !== undefined) button.setAttribute('aria-busy', String(pending)); +} + +export const submitArrowMarkup = 'Submit message'; diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 2f5941275..71b5627b2 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18787,8 +18787,8 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu box-shadow: 0 2px 8px #0f172a33; font-size: 0.875rem; } .chat-steering-toast[hidden], .chat-steering-activity[hidden] { display: none; } -#sendButton[data-submit-mode="steer"] svg path { d: path('M6 19v-6a6 6 0 0 1 6-6h6m-5-5 5 5-5 5'); } -#sendButton[data-submit-mode="queue"] svg { border-bottom: 2px solid currentColor; } +.composer-submit-arrow[data-submit-mode="steer"] svg path { d: path('M6 19v-6a6 6 0 0 1 6-6h6m-5-5 5 5-5 5'); } +.composer-submit-arrow[data-submit-mode="queue"] svg { border-bottom: 2px solid currentColor; } .chat-composer[data-steering-attention="true"] .composer-more-icon::after, .chat-composer[data-steering-attention="true"] .composer-add-icon::after { content: '•'; } #chatSubmitMode { min-height: 44px; max-width: 100%; } @@ -18802,3 +18802,13 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu #chatSubmitModeHelp { display: block; flex-basis: 100%; font-size: 12px; margin: 0; } .unified-message-content .message-view-header { flex-wrap: nowrap; } + +/* Shared arrow semantics for normal chat and direct messages. */ +.composer-submit-arrow { width: 44px; height: 44px; min-width: 44px; min-height: 44px; border-radius: 50%; padding: 10px; flex-shrink: 0; } +.composer-submit-arrow svg { display: block; width: 24px; height: 24px; } +.message-compose-row .composer-submit-arrow, .message-modal-footer .composer-submit-arrow { width: 44px; padding: 10px; border-radius: 50%; } +.message-submit-actions { align-self: flex-end; max-width: 100%; } +.message-submit-actions .conversation-actions-panel { top: auto; bottom: 100%; } +.message-submit-actions select { min-height: 44px; max-width: 100%; } +.message-submit-actions p { font-size: 12px; white-space: normal; overflow-wrap: anywhere; } +.message-modal-content > .message-submit-actions { margin: 0 20px 12px; } diff --git a/tests/backend/test_codex_von_inbox.py b/tests/backend/test_codex_von_inbox.py index 517344464..c1e61a110 100644 --- a/tests/backend/test_codex_von_inbox.py +++ b/tests/backend/test_codex_von_inbox.py @@ -569,3 +569,35 @@ def test_exact_task_in_message_resolves_without_thread_projection(fixture): lookup = context["task_lookup"]["results"][0] assert lookup["status"] == "found" assert lookup["assignment_context"]["evidence"] == "file-copy reference" + + +@pytest.mark.parametrize("mode", ["steer", "unknown", None]) +def test_unsupported_mode_reports_without_model_task_or_duplicate_execution( + fixture, monkeypatch, mode +): + config, api, message, task, comments, sent, transitions, _ = fixture + message["submit_mode"] = mode + monkeypatch.setattr( + inbox.subprocess, + "run", + lambda *a, **k: pytest.fail( + "Cannot execute unsupported steering as queued input" + ), + ) + assert inbox.tick(config, api, 0) + assert not inbox.tick(config, api, 0) + assert transitions == [] + assert len(sent) == 1 + receipt = next(iter(sent.values())) + assert "not applied" in receipt["content"] + assert receipt["reply_to_id"] == message["message_id"] + assert receipt["recipient_ids"] == [config["delegator_id"]] + assert task["status"] == "completed" + + +def test_changed_mode_is_rechecked_before_reply_effect(fixture): + config, api, message, *_ = fixture + original = dict(message, submit_mode="steer") + message["submit_mode"] = "queue" + with pytest.raises(PermissionError, match="changed"): + inbox.authorise_source(config, api, original) diff --git a/tests/backend/test_codex_von_inbox_canonical_replay.py b/tests/backend/test_codex_von_inbox_canonical_replay.py index 4d1d29189..1727b2c38 100644 --- a/tests/backend/test_codex_von_inbox_canonical_replay.py +++ b/tests/backend/test_codex_von_inbox_canonical_replay.py @@ -83,12 +83,19 @@ def test_canonical_delivery_assignment_and_crash_recovery( sender_id="#V#alice", recipient_ids=["#V#worker"], org_id="#V#lab", + metadata={"submit_mode": "queue"}, content=( captured["message"]["content"] if action == "reply" else "Implement the new export control and deploy it. Use gpt-6-astra with xhigh reasoning. Fixture request." ), ) + projected_source = messages.project_direct_message( + messages.get_message_for_user(source["concept_id"], "#V#worker") + ) + assert projected_source["submit_mode"] == "queue" + assert projected_source["recipient_ids"] == ["#V#worker"] + assert inbox.new_messages(config, api)[0]["submit_mode"] == "queue" response = { "answer": ( "Read-only inspection found 20 CPU cores; GPU device access is unavailable." diff --git a/tests/backend/test_message_routes.py b/tests/backend/test_message_routes.py index b01833bb9..d56e36b8e 100644 --- a/tests/backend/test_message_routes.py +++ b/tests/backend/test_message_routes.py @@ -52,6 +52,50 @@ def test_send_message_requires_authentication(monkeypatch, app_client): assert response.get_json()["error"] == "Authentication required" +@pytest.mark.parametrize( + "extra,status,code", + [ + ({"submit_mode": "steer"}, 409, "message_steering_unavailable"), + ({"metadata": {"submit_mode": "steer"}}, 409, "message_steering_unavailable"), + ({"submit_mode": None}, 400, "invalid_submit_intent"), + ( + {"submit_mode": "queue", "metadata": {"submit_mode": "steer"}}, + 400, + "invalid_submit_intent", + ), + ({"submit_target": {"turn_id": "other-turn"}}, 400, "invalid_submit_intent"), + ( + {"metadata": {"submit_target": {"turn_id": "other-turn"}}}, + 400, + "invalid_submit_intent", + ), + ], +) +def test_unsupported_submit_intent_cannot_be_persisted_as_queue( + monkeypatch, app_client, extra, status, code +): + _, client = app_client + _authorise_test_direct_message(monkeypatch) + + def unexpected(**kwargs): + pytest.fail("Unsupported intent must not write a message or launch work") + + monkeypatch.setattr(message_routes, "create_message", unexpected) + monkeypatch.setattr(message_routes, "create_message_idempotently", unexpected) + response = client.post( + "/api/messages/", + json={ + "recipient_ids": ["#V#user_bob"], + "content": "Preserve this draft", + **extra, + }, + ) + assert response.status_code == status + assert response.get_json()["error_code"] == code + if status == 409: + assert response.get_json()["effect_status"] == "not_applied" + + @pytest.mark.parametrize( ("exception_type", "expected_status", "expected_code"), [ @@ -523,6 +567,7 @@ def _fake_create_idempotently(**kwargs): "reused": True, "idempotent_replay": True, "delivery_status": "reused", + "submit_mode": "queue", "message_id": "#V#message_existing", "sent_at": "2026-08-28T05:00:00+00:00", "attribution": "Sent by Von on behalf of #V#user_alice", diff --git a/tests/backend/test_message_service.py b/tests/backend/test_message_service.py index ebb270ec6..f74186851 100644 --- a/tests/backend/test_message_service.py +++ b/tests/backend/test_message_service.py @@ -878,3 +878,64 @@ def test_get_unread_count_empty_user( """get_unread_count() with empty user should return 0.""" result = get_unread_count("") assert result == 0 + + +def test_explicit_queue_retry_matches_pre_mode_delivery_identity(): + intent = dict( + delivery_idempotency_key="retained-draft", + sender_id="#V#alice", + recipient_ids=["#V#worker"], + organisation_concept_id="#V#lab", + content="Keep this message independent", + ) + before = build_direct_message_delivery_identity( + **intent, metadata={"intent": "info"} + ) + after = build_direct_message_delivery_identity( + **intent, metadata={"intent": "info", "submit_mode": "queue"} + ) + assert before == after + + +@pytest.mark.parametrize("idempotent", [False, True]) +def test_service_rejects_steer_before_storage_or_workflow(idempotent): + from src.backend.services.message_service import DirectMessageSteeringUnavailable + + intent = dict( + sender_id="#V#alice", + recipient_ids=["#V#worker"], + content="Scope correction", + metadata={"submit_mode": "steer"}, + ) + with pytest.raises(DirectMessageSteeringUnavailable): + if idempotent: + create_message_idempotently( + **intent, + delivery_idempotency_key="steer-1", + organisation_concept_id="#V#lab", + ) + else: + create_message(**intent, org_id="#V#lab") + + +@pytest.mark.parametrize("mode", ["queue", "steer", "unknown", None]) +def test_projection_preserves_recorded_mode_and_actor_scope(mode): + from src.backend.services.message_service import project_direct_message + + row = project_direct_message( + { + "concept_id": "#V#message", + "relationships": { + PREDICATE_SENDER: ["#V#alice"], + PREDICATE_RECIPIENT: ["#V#worker"], + }, + "concept_data": { + "metadata": {"submit_mode": mode}, + "organisation_concept_id": "#V#lab", + }, + } + ) + assert row["submit_mode"] == mode + assert row["sender_id"] == "#V#alice" + assert row["recipient_ids"] == ["#V#worker"] + assert row["organisation_concept_id"] == "#V#lab" diff --git a/tests/browser/messageSubmitMode.cjs b/tests/browser/messageSubmitMode.cjs new file mode 100644 index 000000000..4950af52f --- /dev/null +++ b/tests/browser/messageSubmitMode.cjs @@ -0,0 +1,100 @@ +// Actual message pane/modules/CSS with isolated synthetic HTTP. No live messages, +// authentication or agent requests; this is presentation and request-intent evidence. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, expect } = require('@playwright/test'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const evidence = process.argv[2] || '.run/message-submit-mode'; +fs.mkdirSync(evidence, { recursive: true }); +let submitted = []; +const server = http.createServer(async (req, res) => { + if (req.url === '/') { + res.setHeader('Content-Type', 'text/html'); + return res.end('
'); + } + if (req.url.startsWith('/static/')) { + const file = path.resolve(root, `.${req.url}`); + if (file.startsWith(root + '/static/') && fs.existsSync(file)) { + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); + return res.end(fs.readFileSync(file)); + } + } + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/api/messages/' && req.method === 'POST') { + let body = ''; for await (const chunk of req) body += chunk; + submitted.push(JSON.parse(body)); + res.statusCode = 201; + return res.end(JSON.stringify({ success: true, message_id: '#V#fixture_message', submit_mode: 'queue' })); + } + res.end(JSON.stringify({ success: true, authenticated: true, current_user_id: '#V#alice', messages: [], profiles: [] })); +}); +async function mount(page) { + await page.evaluate(async () => { + sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#alice' })); + sessionStorage.setItem('von_current_org', JSON.stringify({ concept_id: '#V#lab' })); + const { openMessageExchange } = await import('/static/js/components/messagePanel.js'); + await openMessageExchange({ session_id: 'fixture', viewer_id: '#V#alice', + participant_ids: ['#V#alice', '#V#worker'], other_participant_ids: ['#V#worker'], + organisation_concept_id: '#V#lab', session_name: 'Coding agent' }); + }); +} +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch(); + try { + const results = []; + for (const width of [390, 1280]) { + submitted = []; + const touch = width < 800; + const page = await browser.newPage({ viewport: { width, height: 850 }, hasTouch: touch }); + page.setDefaultTimeout(10000); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await mount(page); + const input = page.locator('#messageInput'); + const button = page.locator('#sendMessageBtn'); + const menu = page.locator('#messageComposeArea .message-submit-actions'); + await input.fill('Keep my multiline\ndraft unchanged.'); + await expect(button).toHaveAttribute('data-submit-mode', 'queue'); + const bounds = await button.boundingBox(); + assert(bounds.width >= 44 && bounds.height >= 44); + await expect(button.locator('svg')).toBeVisible(); + await button.click({ modifiers: ['Shift'] }); + assert.equal(submitted.length, 0); + await expect(input).toHaveValue('Keep my multiline\ndraft unchanged.'); + await menu.locator('summary')[touch ? 'tap' : 'click'](); + await menu.locator('select').selectOption('steer'); + await expect(button).toHaveAccessibleName(/Steering unavailable/); + const panelBounds = await menu.locator('.conversation-actions-panel').boundingBox(); + assert(panelBounds.x >= 0 && panelBounds.x + panelBounds.width <= width); + await page.screenshot({ path: path.join(evidence, `options-${width}.png`) }); + await menu.locator('select').focus(); await page.keyboard.press('Escape'); + await expect(menu.locator('summary')).toBeFocused(); + await button[touch ? 'tap' : 'click'](); + assert.equal(submitted.length, 0); + await expect(input).toHaveValue('Keep my multiline\ndraft unchanged.'); + await page.reload(); await mount(page); + await expect(button).toHaveAttribute('data-submit-mode', 'steer'); + await input.fill('Separate queued request.'); + await menu.locator('summary')[touch ? 'tap' : 'click'](); + await menu.getByRole('button', { name: 'Queue this message', exact: true })[touch ? 'tap' : 'click'](); + await expect(input).toHaveValue(''); + assert.equal(submitted.length, 1); + assert.equal(submitted[0].submit_mode, 'queue'); + assert.deepEqual(submitted[0].recipient_ids, ['#V#worker']); + assert.equal(submitted[0].organisation_concept_id, '#V#lab'); + await expect(button).toHaveAttribute('data-submit-mode', 'steer'); + // Multiline / IME behaviour remains owned by the existing composer. + await input.fill('One'); await input.press('Shift+Enter'); + await expect(input).toHaveValue('One\n'); + assert.equal(submitted.length, 1); + assert.deepEqual(errors, []); + results.push({ width, touch, bounds, panelBounds, savedDefaultAfterReload: true, + unsupportedDraftRetained: true, queuedPayload: submitted[0], noLiveDeliveryClaim: true }); + await page.close(); + } + fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, results }, null, 2)); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; }); diff --git a/tests/frontend/messagePanelSendFailure.test.js b/tests/frontend/messagePanelSendFailure.test.js index a1863b317..4a1f79b1a 100644 --- a/tests/frontend/messagePanelSendFailure.test.js +++ b/tests/frontend/messagePanelSendFailure.test.js @@ -207,6 +207,7 @@ describe('message panel send failure recovery', () => { await flushUi(); expect(postJsonDetailed).toHaveBeenNthCalledWith(2, '/api/messages/', { + submit_mode: 'queue', recipient_ids: ['#V#user_bob'], content: 'Please review the draft.', delivery_idempotency_key: expect.any(String), @@ -390,7 +391,7 @@ describe('message panel send failure recovery', () => { expect(postJsonDetailed).toHaveBeenCalledTimes(1); expect(sendButton.disabled).toBe(true); - expect(sendButton.textContent).toBe('Sending…'); + expect(sendButton.getAttribute('aria-label')).toBe('Submitting message'); expect(sendButton.getAttribute('aria-busy')).toBe('true'); expect(replyInput.value).toBe('Please review this once.'); @@ -407,7 +408,7 @@ describe('message panel send failure recovery', () => { await flushUi(); expect(sendButton.disabled).toBe(false); - expect(sendButton.textContent).toBe('Send'); + expect(sendButton.getAttribute('aria-label')).toBe('Submit separate message'); expect(sendButton.getAttribute('aria-busy')).toBe('false'); expect(replyInput.value).toBe('Please review this once.'); @@ -427,7 +428,7 @@ describe('message panel send failure recovery', () => { expect(replyInput.value).toBe(''); expect(sendButton.disabled).toBe(false); - expect(sendButton.textContent).toBe('Send'); + expect(sendButton.getAttribute('aria-label')).toBe('Submit separate message'); }); test('new-message retries get new delivery keys after recipient or content changes', async () => { @@ -463,7 +464,7 @@ describe('message panel send failure recovery', () => { expect(postJsonDetailed).toHaveBeenCalledTimes(1); expect(sendButton.disabled).toBe(true); - expect(sendButton.textContent).toBe('Sending…'); + expect(sendButton.getAttribute('aria-label')).toBe('Submitting message'); expect(recipientInput.disabled).toBe(true); expect(contentInput.disabled).toBe(true); sendButton.click(); @@ -497,7 +498,7 @@ describe('message panel send failure recovery', () => { expect(recipientInput.value).toBe('user_alice'); expect(contentInput.value).toBe('Changed draft'); expect(sendButton.disabled).toBe(false); - expect(sendButton.textContent).toBe('Send'); + expect(sendButton.getAttribute('aria-label')).toBe('Submit separate message'); }); test('new-message retry restores its exact recipient, draft, and key across render and module reload', async () => { diff --git a/tests/frontend/messageSubmitControls.test.js b/tests/frontend/messageSubmitControls.test.js new file mode 100644 index 000000000..7956d642f --- /dev/null +++ b/tests/frontend/messageSubmitControls.test.js @@ -0,0 +1,71 @@ +/** @jest-environment jsdom */ +import { createMessageSubmitControls } from '../../src/frontend/web/von_interface/static/js/components/messageSubmitControls.js'; +import { createChatSteeringControls } from '../../src/frontend/web/von_interface/static/js/components/chatSteeringControls.js'; +import { getSubmitMode, setSubmitMode } from '../../src/frontend/web/von_interface/static/js/components/submitMode.js'; + +let controls; +let actor; +let submit; +let unavailable; +let button; +beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = '
'; + actor = 'alice'; submit = jest.fn(); unavailable = jest.fn(); + button = document.getElementById('submit'); + controls = createMessageSubmitControls({ button, getActor: () => actor, onSubmit: submit, onUnavailable: unavailable }); +}); +afterEach(() => controls.dispose()); + +test('arrow is icon-only with accessible mode; Shift is one-time and never sends unsupported steering', () => { + expect(button.querySelector('svg')).not.toBeNull(); + expect(button.getAttribute('aria-label')).toBe('Submit separate message'); + controls.activate({ shiftKey: true }); + expect(submit).not.toHaveBeenCalled(); + expect(unavailable).toHaveBeenCalledWith(expect.stringContaining('Draft and attachments retained')); + expect(getSubmitMode(actor)).toBe('queue'); + controls.activate(); + expect(submit).toHaveBeenCalledTimes(1); +}); + +test('saved preference is shared live with normal chat; alternate action and actor change remain independent', () => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = ''; + document.body.append(wrapper); + const chat = createChatSteeringControls({ sendButton: wrapper.firstChild, request: async () => ({ items: [] }), getDraft: () => '', clearDraft() {}, createId: () => 'fixture' }); + try { + chat.update({ actorKey: actor, scopeKey: actor, busy: false }); + const chatSelect = document.getElementById('chatSubmitMode'); + chatSelect.value = 'steer'; chatSelect.dispatchEvent(new Event('change')); + expect(button.dataset.submitMode).toBe('steer'); + expect(button.getAttribute('aria-label')).toContain('unavailable'); + controls.activate(); + expect(submit).not.toHaveBeenCalled(); + controls.activate({ shiftKey: true }); + expect(submit).toHaveBeenCalledTimes(1); + expect(getSubmitMode(actor)).toBe('steer'); + const selector = document.querySelector('.message-submit-actions select'); + selector.value = 'queue'; selector.dispatchEvent(new Event('change')); + expect(chatSelect.value).toBe('queue'); + setSubmitMode('alice', 'steer'); + actor = 'bob'; controls.update(); + expect(button.dataset.submitMode).toBe('queue'); + } finally { chat.dispose(); } +}); + +test('touch/keyboard explicit queue respects pending and keeps the saved default', () => { + setSubmitMode(actor, 'steer'); + const actions = document.querySelectorAll('.message-submit-actions button'); + actions[0].click(); + expect(submit).toHaveBeenCalledTimes(1); + expect(getSubmitMode(actor)).toBe('steer'); + button.disabled = true; controls.update(true); + actions[0].click(); controls.activate({ shiftKey: true }); + expect(submit).toHaveBeenCalledTimes(1); + expect(button.querySelector('svg')).not.toBeNull(); + expect(button.getAttribute('aria-busy')).toBe('true'); + const details = document.querySelector('details'); details.open = true; + details.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(details.open).toBe(false); + expect(document.activeElement).toBe(details.querySelector('summary')); +}); From 9823ad7fed8e864788bbf4c5a6d2ef9015e044c2 Mon Sep 17 00:00:00 2001 From: witbrock Date: Thu, 17 Sep 2026 12:58:54 -0400 Subject: [PATCH 2/4] Publish exact coding turn targets and retain steering delivery receipts --- scripts/codex_von_inbox.py | 18 +- scripts/codex_von_steering.py | 34 ++- scripts/codex_von_worker.py | 17 ++ src/backend/server/routes/message_routes.py | 94 ++++++- .../services/coding_agent_steering_service.py | 198 +++++++++++++ src/backend/services/message_service.py | 35 ++- .../static/js/components/messagePanel.js | 57 +++- .../js/components/messageSubmitControls.js | 13 +- tests/backend/test_codex_von_app_server.py | 12 +- tests/backend/test_codex_von_steering.py | 37 +++ .../test_coding_agent_steering_service.py | 260 ++++++++++++++++++ tests/browser/messageSubmitMode.cjs | 19 +- tests/frontend/messageExchangeView.test.js | 51 ++++ 13 files changed, 804 insertions(+), 41 deletions(-) create mode 100644 src/backend/services/coding_agent_steering_service.py create mode 100644 tests/backend/test_coding_agent_steering_service.py diff --git a/scripts/codex_von_inbox.py b/scripts/codex_von_inbox.py index 9e6aa835b..0e4b8fcce 100644 --- a/scripts/codex_von_inbox.py +++ b/scripts/codex_von_inbox.py @@ -353,13 +353,13 @@ def prepare_attachment_inputs(config, context, run): def launch(config, state, lock_fd): # A retained/externally-created steering message cannot be interpreted as a - # fresh task after its intended turn has ended. This exec route has no live - # turn transport yet. Report non-delivery without asking a model to execute it. + # fresh task after its intended turn has ended. Report non-delivery without + # asking a model to execute it. if state["context"]["message"].get("submit_mode", "queue") != "queue": state["result"] = { "answer": ( - "This message was not applied: active steering is unavailable on " - "the installed DGX inbox route. The original message is retained. " + "This message was not applied to its intended active turn. " + "The original message and target are retained. " "Choose Queue explicitly to request separate work." ), "task_id": "", @@ -367,6 +367,9 @@ def launch(config, state, lock_fd): "deployment_requested": False, "new_task": None, } + target = state["context"]["message"].get("submit_target") + if isinstance(target, dict) and target.get("agent_id") == config["agent_id"]: + state["steering_delivery"] = {"status": "not_applied", "binding": target} state["phase"] = "reporting" return run = Path(state["run_dir"]) @@ -516,6 +519,13 @@ def finish(config, api, state, path): message = state["context"]["message"] state["effect_stage"] = "source_authorisation" authorise_source(config, api, message) + if state.get("steering_delivery"): + try: + from .codex_von_steering import reconcile_receipt + except ImportError: + from codex_von_steering import reconcile_receipt + state["effect_stage"] = "steering_receipt" + reconcile_receipt(api, state, path) # Check again at the effect boundary, including recovered on-disk states. result = state["result"] = validate_result(state["result"], state["context"]) task_id = result["task_id"] diff --git a/scripts/codex_von_steering.py b/scripts/codex_von_steering.py index 735b1a415..22075bd6d 100644 --- a/scripts/codex_von_steering.py +++ b/scripts/codex_von_steering.py @@ -1,8 +1,7 @@ """Attempt-bound input capture within the existing DGX controller lock. -Public message admission remains disabled until a canonical active-target -publication/read-back route and host acceptance are supplied. This receiver does -not manufacture a target from whichever task happens to be running. +Canonical admission publishes the existing owner's exact target. This receiver +rechecks it and never manufactures a target from whichever task is running. """ from __future__ import annotations @@ -42,12 +41,28 @@ def recover_delivery(state): ) +def reconcile_receipt(api, state, path): + """Retry a retained receipt, never the guidance or a model execution.""" + receipt = state.get("steering_delivery") + if not receipt or receipt.get("canonical_readback"): + return + api.record_steering_delivery( + state["context"]["message"]["message_id"], + receipt["binding"], + receipt["status"], + ) + receipt["canonical_readback"] = True + receipt.pop("canonical_receipt_error", None) + worker.write_json(path, state) + + class ActiveInbox: def __init__(self, config, api, state, save): self.config, self.api, self.state, self.save = config, api, state, save self.binding = None def active(self, thread_id, turn_id): + previous = self.binding self.binding = ( None if not turn_id @@ -60,6 +75,14 @@ def active(self, thread_id, turn_id): "turn_id": turn_id, } ) + try: + if self.binding: + self.api.publish_steering_target(self.binding) + elif previous: + self.api.withdraw_steering_target(previous) + self.state.pop("steering_publication_error", None) + except Exception as exc: + self.state["steering_publication_error"] = type(exc).__name__ self.state["active_turn"] = self.binding self.save() @@ -142,3 +165,8 @@ def delivered(self, message, status): ) recover_delivery(state) worker.write_json(path, state) + try: + reconcile_receipt(self.api, state, path) + except Exception as exc: + state["steering_delivery"]["canonical_receipt_error"] = type(exc).__name__ + worker.write_json(path, state) diff --git a/scripts/codex_von_worker.py b/scripts/codex_von_worker.py index 62ed636c7..fa04aa8f2 100755 --- a/scripts/codex_von_worker.py +++ b/scripts/codex_von_worker.py @@ -822,6 +822,21 @@ def send(self, task, key, content): raise RuntimeError("Direct message canonical read-back failed") return message_id + def publish_steering_target(self, binding): + from src.backend.services.coding_agent_steering_service import publish + + return publish(binding, delegator_id=self.config["delegator_id"]) + + def withdraw_steering_target(self, binding): + from src.backend.services.coding_agent_steering_service import withdraw + + withdraw(binding) + + def record_steering_delivery(self, message_id, binding, status): + from src.backend.services.coding_agent_steering_service import record_delivery + + return record_delivery(message_id, binding, status) + def start_execution(self, state, *, source="codex_dgx"): from src.backend.services import task_execution_timing_service as timing @@ -1319,6 +1334,8 @@ def launch_app_server( inputs = ActiveInbox(config, api, state, save) def checkpoint(): + if inputs.binding: + inputs.active(inputs.binding["thread_id"], inputs.binding["turn_id"]) archive_checkpoint(config, state) save() diff --git a/src/backend/server/routes/message_routes.py b/src/backend/server/routes/message_routes.py index e7012c9d1..e22b28788 100644 --- a/src/backend/server/routes/message_routes.py +++ b/src/backend/server/routes/message_routes.py @@ -154,7 +154,9 @@ def _get_current_org_concept_id( def _get_message_metadata(message_doc: Any) -> dict[str, Any]: - concept_data = message_doc.get("concept_data") if isinstance(message_doc, dict) else {} + concept_data = ( + message_doc.get("concept_data") if isinstance(message_doc, dict) else {} + ) metadata = concept_data.get("metadata") if isinstance(concept_data, dict) else {} return dict(metadata) if isinstance(metadata, dict) else {} @@ -165,20 +167,26 @@ def _resolve_recommendation_subject_id( current_user_concept_id: str | None, ) -> Optional[str]: metadata = _get_message_metadata(message_doc) - subject_id = _normalise_concept_id(metadata.get("recommendation_subject_concept_id")) + subject_id = _normalise_concept_id( + metadata.get("recommendation_subject_concept_id") + ) if subject_id: return subject_id if current_user_concept_id: return _normalise_concept_id(current_user_concept_id) - relationships = message_doc.get("relationships") if isinstance(message_doc, dict) else {} + relationships = ( + message_doc.get("relationships") if isinstance(message_doc, dict) else {} + ) recipient_ids = _normalise_concept_id_list( relationships.get("#V#has_recipient") if isinstance(relationships, dict) else [] ) return recipient_ids[0] if recipient_ids else None -def _resolve_message_recommendation_assertion_ids(message_doc: dict[str, Any]) -> list[str]: +def _resolve_message_recommendation_assertion_ids( + message_doc: dict[str, Any], +) -> list[str]: metadata = _get_message_metadata(message_doc) return _normalise_concept_id_list(metadata.get("recommendation_assertion_ids")) @@ -260,9 +268,7 @@ def _build_common_organisation_options( membership_map: dict[str, str] = {} membership_entries = ( - memberships.get("memberships", []) - if isinstance(memberships, dict) - else [] + memberships.get("memberships", []) if isinstance(memberships, dict) else [] ) for membership in membership_entries: if not isinstance(membership, dict): @@ -274,9 +280,7 @@ def _build_common_organisation_options( continue role = membership.get("role") membership_map[organisation_concept_id] = ( - role.strip() - if isinstance(role, str) and role.strip() - else "member" + role.strip() if isinstance(role, str) and role.strip() else "member" ) membership_maps.append(membership_map) @@ -308,6 +312,36 @@ def _build_common_organisation_options( return options +@message_bp.route("/steering-target", methods=["GET"]) +def get_coding_steering_target(): + from ...services.coding_agent_steering_service import resolve + + sender = _get_current_user_concept_id() + if not sender: + return jsonify(error="Authentication required"), 401 + recipient = _normalise_concept_id(request.args.get("recipient_id")) + org = _normalise_concept_id(request.args.get("organisation_concept_id")) + if not recipient or not org: + return ( + jsonify( + available=False, + reason="Choose a coding recipient and shared organisation", + ), + 200, + ) + try: + target = resolve(sender_id=sender, recipient_id=recipient, organisation_id=org) + return jsonify(available=True, submit_target=target), 200 + except (DirectMessageSteeringUnavailable, PermissionError): + return ( + jsonify( + available=False, + reason="No active steering target. Queue remains available.", + ), + 200, + ) + + @message_bp.route("/", methods=["POST"]) def send_message() -> ResponseReturnValue: """Send a new message. @@ -478,12 +512,26 @@ def send_message() -> ResponseReturnValue: raise ValueError("Conflicting submit modes") if any( key in data or key in metadata_payload - for key in ("submit_target", "submit_intent", "steering_target") + for key in ("submit_intent", "steering_target") ): raise ValueError( "Active-turn targets are not supported by this message route" ) - metadata_payload["submit_mode"] = validate_direct_message_submit_mode(mode) + supplied_target = data.get( + "submit_target", metadata_payload.get("submit_target") + ) + if ( + "submit_target" in metadata_payload + and metadata_payload["submit_target"] != supplied_target + ): + raise ValueError("Conflicting submit targets") + if supplied_target is not None: + if mode != "steer" or not isinstance(supplied_target, dict): + raise ValueError("Only Steering accepts an exact active target") + metadata_payload["submit_target"] = supplied_target + metadata_payload["submit_mode"] = validate_direct_message_submit_mode( + mode, target=supplied_target + ) except DirectMessageSteeringUnavailable as exc: return ( jsonify( @@ -590,6 +638,18 @@ def send_message() -> ResponseReturnValue: "idempotent_replay": reused, "delivery_status": delivery_status, "submit_mode": metadata_payload["submit_mode"], + **( + { + "steering_delivery": ( + (message.get("concept_data") or {}).get( + "steering_delivery" + ) + or {"status": "pending", "consumption_verified": False} + ) + } + if mode == "steer" + else {} + ), "message_id": message.get("concept_id"), "conversation": conversation, "sent_at": message.get("concept_data", {}).get("sent_at"), @@ -602,6 +662,16 @@ def send_message() -> ResponseReturnValue: 200 if reused else 201, ) + except DirectMessageSteeringUnavailable as exc: + return ( + jsonify( + error=str(exc), + error_code="message_steering_unavailable", + effect_status="not_applied", + retryable=False, + ), + 409, + ) except DirectMessageIdempotencyConflict as exc: return ( jsonify( diff --git a/src/backend/services/coding_agent_steering_service.py b/src/backend/services/coding_agent_steering_service.py new file mode 100644 index 000000000..5779f2e7a --- /dev/null +++ b/src/backend/services/coding_agent_steering_service.py @@ -0,0 +1,198 @@ +"""Controller-owned active-turn observations and exact direct-message admission. + +This operational registry is not task authority. Every read checks the live +assignment; the existing owner rechecks the exact binding before dispatch. +Only an authenticated in-process controller publishes, never a browser/model. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from ..db.mongo_client import get_db +from ..security.access_control import get_effective_user_concept_id + +COLLECTION = "coding_agent_active_turns" +TARGET_KEYS = { + "agent_id", + "organisation_id", + "task_id", + "attempt", + "thread_id", + "turn_id", +} +# Three missed 15-second observations plus conservative scheduler/store headroom. +OWNER_OBSERVATION_SECONDS = 180 + + +def _collection(): + db = get_db() + if db is None: + raise RuntimeError("Coding owner observation store unavailable") + return db[COLLECTION] + + +def _task(binding, delegator): + from . import task_management_service as tasks + from .task_dispatch_authority_service import ( + has_assignment_record, + is_authorised_assignment, + ) + + try: + task = tasks.get_task(binding["task_id"]) + except tasks.TaskNotFoundError as exc: + raise PermissionError("Coding task is no longer visible") from exc + if ( + not task + or task.get("status") != "in_progress" + or task.get("assignee_concept_id") != binding["agent_id"] + ): + raise PermissionError("Coding attempt is no longer active for this actor") + if task.get("organisation_concept_id") not in (None, binding["organisation_id"]): + raise PermissionError("Coding task organisation changed") + # Reuse the canonical assignment-authority decision used by the worker. + legacy_local_assignment = ( + not task.get("federation") + and task.get("organisation_concept_id") == binding["organisation_id"] + and task.get("created_by_concept_id") == delegator + and not has_assignment_record(binding["task_id"]) + ) + if not legacy_local_assignment and not ( + task.get("dispatch_requested") and is_authorised_assignment(task, delegator) + ): + raise PermissionError("Sender is not the coding task delegator") + attempts = (task.get("execution_timing") or {}).get("attempts", []) + attempt = next( + (a for a in attempts if a.get("attempt_id") == binding["attempt"]), None + ) + if ( + not attempt + or attempt != attempts[-1] + or attempt.get("ended_at") + or attempt.get("outcome") + or attempt.get("actor_concept_id") != binding["agent_id"] + ): + raise PermissionError("Coding execution attempt is not current") + return task + + +def publish(binding, *, delegator_id): + if ( + not isinstance(binding, dict) + or set(binding) != TARGET_KEYS + or not all(isinstance(v, str) and v for v in binding.values()) + ): + raise ValueError("An exact coding turn binding is required") + if get_effective_user_concept_id() != binding["agent_id"]: + raise PermissionError("Only the bound coding owner can publish its turn") + _task(binding, delegator_id) + now = datetime.now(UTC) + record = { + "_id": binding["agent_id"] + ":" + binding["organisation_id"], + "binding": binding, + "delegator_id": delegator_id, + "observed_at": now, + "expires_at": now + timedelta(seconds=OWNER_OBSERVATION_SECONDS), + } + _collection().replace_one({"_id": record["_id"]}, record, upsert=True) + found = _collection().find_one({"_id": record["_id"]}) + if not found or found["binding"] != binding: + raise RuntimeError("Coding target canonical read-back failed") + return binding + + +def withdraw(binding): + if get_effective_user_concept_id() != binding["agent_id"]: + raise PermissionError("Only the coding owner can withdraw its turn") + _collection().delete_one( + { + "_id": binding["agent_id"] + ":" + binding["organisation_id"], + "binding": binding, + } + ) + + +def resolve(*, sender_id, recipient_id, organisation_id): + from .message_service import ( + DirectMessageSteeringUnavailable, + authorise_direct_message_participants, + ) + + if get_effective_user_concept_id() != sender_id: + raise PermissionError("Steering lookup requires the authenticated sender") + allowed, _ = authorise_direct_message_participants( + sender_id=sender_id, + recipient_ids=[recipient_id], + organisation_concept_id=organisation_id, + ) + if not allowed: + raise DirectMessageSteeringUnavailable( + "No active steering target is available in this message context" + ) + row = _collection().find_one( + { + "_id": recipient_id + ":" + organisation_id, + "delegator_id": sender_id, + "expires_at": {"$gt": datetime.now(UTC)}, + } + ) + if row: + try: + _task(row["binding"], sender_id) + return dict(row["binding"]) + except (PermissionError, ValueError): + pass + raise DirectMessageSteeringUnavailable( + "Active steering is unavailable. Draft retained; choose Queue for separate work." + ) + + +def validate_submission(*, sender_id, recipient_ids, organisation_id, target): + from .message_service import DirectMessageSteeringUnavailable + + if len(recipient_ids) != 1 or not organisation_id: + raise DirectMessageSteeringUnavailable( + "Steering requires one coding recipient in a shared organisation" + ) + current = resolve( + sender_id=sender_id, + recipient_id=recipient_ids[0], + organisation_id=organisation_id, + ) + if target != current: + raise DirectMessageSteeringUnavailable( + "The intended coding turn changed. Draft retained; review the current target before retrying." + ) + return current + + +def record_delivery(message_id, binding, status): + from . import message_service as messages + from ..db.repositories.concepts_repository import ConceptsRepository + + if ( + status not in {"accepted", "not_applied", "uncertain"} + or get_effective_user_concept_id() != binding["agent_id"] + ): + raise PermissionError("Only the owning controller can record delivery") + message = messages.get_message_for_user(message_id, binding["agent_id"]) + if ( + not message + or messages.project_direct_message(message).get("submit_target") != binding + ): + raise PermissionError("Message target does not match the controller receipt") + receipt = { + "status": status, + "target": binding, + "observed_at": datetime.now(UTC).isoformat(), + "consumption_verified": False, + } + ConceptsRepository.update_one( + {"concept_id": message_id}, + {"$set": {"concept_data.steering_delivery": receipt}}, + ) + result = messages.get_message_for_user(message_id, binding["agent_id"]) + if (result or {}).get("concept_data", {}).get("steering_delivery") != receipt: + raise RuntimeError("Steering receipt canonical read-back failed") + return receipt diff --git a/src/backend/services/message_service.py b/src/backend/services/message_service.py index 2c263b489..6728cba9b 100644 --- a/src/backend/services/message_service.py +++ b/src/backend/services/message_service.py @@ -73,10 +73,10 @@ class DirectMessageSteeringUnavailable(ValueError): """No receiving-controller active-turn binding is available on this route.""" -def validate_direct_message_submit_mode(mode: Any = "queue") -> str: +def validate_direct_message_submit_mode(mode: Any = "queue", *, target=None) -> str: if mode not in ("queue", "steer"): raise ValueError("submit_mode must be queue or steer") - if mode == "steer": + if mode == "steer" and not target: raise DirectMessageSteeringUnavailable( "Active steering is unavailable on this route. Retain the draft or " "explicitly choose Queue for separate work." @@ -86,11 +86,21 @@ def validate_direct_message_submit_mode(mode: Any = "queue") -> str: def _validate_submit_metadata(metadata: Optional[Dict[str, Any]]) -> None: metadata = metadata if isinstance(metadata, dict) else {} - validate_direct_message_submit_mode(metadata.get("submit_mode", "queue")) - if any( - key in metadata for key in ("submit_target", "submit_intent", "steering_target") - ): - raise ValueError("Active-turn targets are not supported by this message route") + validate_direct_message_submit_mode( + metadata.get("submit_mode", "queue"), target=metadata.get("submit_target") + ) + if any(key in metadata for key in ("submit_intent", "steering_target")): + raise ValueError("Unsupported active-turn target alias") + if metadata.get("submit_target") is not None: + from .coding_agent_steering_service import TARGET_KEYS + + target = metadata["submit_target"] + if ( + metadata.get("submit_mode") != "steer" + or not isinstance(target, dict) + or set(target) != TARGET_KEYS + ): + raise ValueError("Steering requires an exact active-turn target") @dataclass(frozen=True) @@ -393,6 +403,7 @@ def relationship_values(predicate: str) -> List[str]: # dropping them would turn old steering intents into new queued work. "submit_mode": metadata.get("submit_mode", "queue"), "submit_target": metadata.get("submit_target"), + "steering_delivery": concept_data.get("steering_delivery"), } @@ -449,6 +460,16 @@ def create_message( if not recipient_ids: raise ValueError("At least one valid recipient is required") + if (metadata or {}).get("submit_mode") == "steer": + from .coding_agent_steering_service import validate_submission + + validate_submission( + sender_id=sender_id, + recipient_ids=recipient_ids, + organisation_id=org_id, + target=metadata.get("submit_target"), + ) + timestamp = datetime.now(timezone.utc) concept_id = _generate_message_concept_id(sender_id, timestamp) diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js index aac209615..ba192cc38 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -553,7 +553,7 @@ function renderMessagesTabContent() { _newSubmitControls?.dispose(); const submitControls = (button, onSubmit) => createMessageSubmitControls({ button, optionsPanel: button.closest('.message-draft-shell')?.querySelector('.composer-options-panel'), - getActor: readSessionActorConceptId, onSubmit, + getActor: readSessionActorConceptId, onSubmit, onSteer: () => onSubmit('steer'), onUnavailable: text => showToast(text, 'error') }); _replySubmitControls = submitControls(_messagesContainer.querySelector('#sendMessageBtn'), handleSendReply); @@ -1305,6 +1305,7 @@ async function renderMessages(onRendered = () => {}) {
${escapeHtml(formatUserName(senderId))}
${subject ? `
${escapeHtml(subject)}
` : ''}
${simpleMarkdownToHtml(content)}
+ ${steeringReceiptLabel(msg) ? `
${escapeHtml(steeringReceiptLabel(msg))}
` : ''} ${isRecommendationMessage ? `
@@ -1650,6 +1651,8 @@ function persistReplyDeliveryAttempt(attempt) { scope: attempt.scope, session_scope: attempt.sessionScope, recovery_state: attempt.recoveryState || null, + submit_mode: attempt.submitMode || 'queue', + submit_target: attempt.submitTarget || null, })); } catch (_) { // The in-memory attempt still protects the current rendered tab. @@ -1724,6 +1727,8 @@ function readPersistedReplyDeliveryAttempt() { scope: normalisedScope, sessionScope, recoveryState, + submitMode: stored.submit_mode || 'queue', + submitTarget: stored.submit_target || null, }; } @@ -1761,6 +1766,8 @@ function persistNewMessageDeliveryAttempt(attempt) { scope: sessionScope, delivery_scope: attempt.scope, recovery_state: attempt.recoveryState || null, + submit_mode: attempt.submitMode || 'queue', + submit_target: attempt.submitTarget || null, })); } catch (_) { // The in-memory attempt still protects the current rendered tab. @@ -1841,6 +1848,8 @@ function readPersistedNewMessageDeliveryAttempt() { scope: deliveryScope, sessionScope, recoveryState, + submitMode: stored.submit_mode || 'queue', + submitTarget: stored.submit_target || null, }; } @@ -2076,6 +2085,7 @@ function getOrCreateDeliveryIdempotencyKey({ organisationConceptId, actorUserId, threadId, + submitMode = 'queue', }) { const deliveryScope = buildDeliveryScope({ recipientIds, @@ -2095,6 +2105,7 @@ function getOrCreateDeliveryIdempotencyKey({ ); if ( currentAttempt?.fingerprint === fingerprint + && (currentAttempt.submitMode || 'queue') === submitMode && currentAttempt?.key && sameSessionScope ) { @@ -2112,6 +2123,7 @@ function getOrCreateDeliveryIdempotencyKey({ const key = createDeliveryIdempotencyKey(); setDeliveryAttempt(scope, { fingerprint, + submitMode, key, draft, sessionScope, @@ -2369,6 +2381,7 @@ function buildSendPayload({ visibleDraft = content, visibleRecipient, threadId = '', + submitMode = 'queue', }) { const failureState = getComposeFailureState(scope); const organisationOptions = Array.isArray(failureState?.organisationOptions) @@ -2397,11 +2410,12 @@ function buildSendPayload({ visibleRecipient, organisationConceptId: selectedOrganisationConceptId, threadId, + submitMode, }); return { recipient_ids: recipientIds, - submit_mode: 'queue', + submit_mode: submitMode, content, ...(attachmentIds.length ? {attachment_ids: attachmentIds} : {}), delivery_idempotency_key: deliveryIdempotencyKey, @@ -2411,6 +2425,33 @@ function buildSendPayload({ }; } +async function prepareSteeringPayload(payload, scope) { + if (payload.submit_mode !== 'steer') return true; + const attempt = getDeliveryAttempt(scope); + if (attempt?.submitTarget) { + payload.submit_target = attempt.submitTarget; + return true; // Retry the exact original target; never silently retarget. + } + const params = new URLSearchParams({ recipient_id: payload.recipient_ids[0], organisation_concept_id: payload.organisation_concept_id || '' }); + const result = await getJson(`/api/messages/steering-target?${params}`); + if (!result.available || !result.submit_target) { + showToast(result.reason || 'Steering unavailable. Draft retained; choose Queue for separate work.', 'warning'); + return false; + } + payload.submit_target = result.submit_target; + setDeliveryAttempt(scope, { ...attempt, submitTarget: result.submit_target }); + return true; +} + +function steeringReceiptLabel(message) { + if (message.concept_data?.metadata?.submit_mode !== 'steer') return ''; + const status = message.concept_data?.steering_delivery?.status; + return ({ accepted: 'Guidance accepted by the intended turn; consumption not verified.', + not_applied: 'Guidance was not applied to the intended turn.', + uncertain: 'Guidance delivery is uncertain; it has not been retried or queued.' })[status] + || 'Guidance stored for the intended turn; delivery pending.'; +} + /** * Handle sending a reply in the current conversation. */ @@ -2424,7 +2465,7 @@ async function finishMessageDictation(scope) { finally { finishingDictation.delete(scope); } } -async function handleSendReply() { +async function handleSendReply(submitMode = 'queue') { if (messageDictation.get(COMPOSE_SCOPE_REPLY)?.hasPendingInput() && !await finishMessageDictation(COMPOSE_SCOPE_REPLY)) return; if (isComposePending(COMPOSE_SCOPE_REPLY)) { @@ -2453,6 +2494,7 @@ async function handleSendReply() { content, visibleDraft, threadId: resolveCurrentReplyThreadId(), + submitMode, }); if (!payload) { return; @@ -2464,6 +2506,7 @@ async function handleSendReply() { messageHistoryCursors.delete(submittedAttachmentScope); setComposePending(COMPOSE_SCOPE_REPLY, true); try { + if (payload.submit_mode === 'steer' && !await prepareSteeringPayload(payload, COMPOSE_SCOPE_REPLY)) return; const result = await postJsonDetailed('/api/messages/', payload); removeSentAttachments(submittedAttachmentScope, payload.attachment_ids || []); refreshReplyAttachments(); refreshNewAttachments(); @@ -2482,7 +2525,7 @@ async function handleSendReply() { } _exchangeDrafts.delete(exchangeScope()); document.dispatchEvent(new CustomEvent('von:conversation-contribution')); - showToast('Message sent', 'success'); + showToast(payload.submit_mode === 'steer' ? 'Guidance stored; active-turn delivery pending.' : 'Message sent', 'success'); if (_exchange && result.data?.conversation && result.data.conversation.session_id !== _exchange.session_id) { // An explicit organisation recovery starts its own exchange; it @@ -2547,7 +2590,7 @@ function hideNewMessageModal({ force = false } = {}) { /** * Handle sending a new message from the modal. */ -async function handleSendNewMessage() { +async function handleSendNewMessage(submitMode = 'queue') { if (messageDictation.get(COMPOSE_SCOPE_NEW_MESSAGE)?.hasPendingInput() && !await finishMessageDictation(COMPOSE_SCOPE_NEW_MESSAGE)) return; if (isComposePending(COMPOSE_SCOPE_NEW_MESSAGE)) { @@ -2584,6 +2627,7 @@ async function handleSendNewMessage() { content, visibleDraft, visibleRecipient, + submitMode, }); if (!payload) { return; @@ -2593,6 +2637,7 @@ async function handleSendNewMessage() { const submittedAttachmentScope = attachmentScope(COMPOSE_SCOPE_NEW_MESSAGE); setComposePending(COMPOSE_SCOPE_NEW_MESSAGE, true); try { + if (payload.submit_mode === 'steer' && !await prepareSteeringPayload(payload, COMPOSE_SCOPE_NEW_MESSAGE)) return; const result = await postJsonDetailed('/api/messages/', payload); removeSentAttachments(submittedAttachmentScope, payload.attachment_ids || []); refreshReplyAttachments(); refreshNewAttachments(); @@ -2601,7 +2646,7 @@ async function handleSendNewMessage() { resetComposeFailureUi(COMPOSE_SCOPE_NEW_MESSAGE); setDeliveryAttempt(COMPOSE_SCOPE_NEW_MESSAGE, null); document.dispatchEvent(new CustomEvent('von:conversation-contribution')); - showToast('Message sent', 'success'); + showToast(payload.submit_mode === 'steer' ? 'Guidance stored; active-turn delivery pending.' : 'Message sent', 'success'); hideNewMessageModal({ force: true }); if (result.data?.conversation) { diff --git a/src/frontend/web/von_interface/static/js/components/messageSubmitControls.js b/src/frontend/web/von_interface/static/js/components/messageSubmitControls.js index 29636679e..ed1d5de72 100644 --- a/src/frontend/web/von_interface/static/js/components/messageSubmitControls.js +++ b/src/frontend/web/von_interface/static/js/components/messageSubmitControls.js @@ -2,7 +2,7 @@ import { getSubmitMode, setSubmitMode, selectedSubmitMode, presentSubmitMode, su // This route currently has no verified live-turn transport. Never relabel queue // acceptance as steering; retain the draft so the sender can choose separate work. -export function createMessageSubmitControls({ button, optionsPanel, getActor, onSubmit, onUnavailable }) { +export function createMessageSubmitControls({ button, optionsPanel, getActor, onSubmit, onUnavailable, onSteer }) { button.innerHTML = submitArrowMarkup; const menu = optionsPanel?.closest('details') || document.createElement('details'); const optionsName = optionsPanel ? 'More actions' : 'Submit options'; @@ -25,11 +25,11 @@ export function createMessageSubmitControls({ button, optionsPanel, getActor, on label.append(select); const help = document.createElement('p'); help.id = `${button.id}ModeHelp`; - help.textContent = 'Queue sends a separate message. Active coding-agent steering is not available on this route yet. Choosing Steering retains your draft. Shift-click the arrow for the opposite action once; Shift+Enter inserts a newline. Choose either action here with keyboard or touch.'; + help.textContent = onSteer ? 'Queue sends separate work. Steering checks the recipient’s active coding turn. If unavailable, your draft stays here. Shift-click chooses the opposite action once.' : 'Queue sends a separate message. Active coding-agent steering is not available on this route yet. Choosing Steering retains your draft. Shift-click the arrow for the opposite action once; Shift+Enter inserts a newline. Choose either action here with keyboard or touch.'; select.setAttribute('aria-describedby', help.id); button.setAttribute('aria-describedby', help.id); const actions = []; - for (const [mode, text] of [['queue', 'Queue this message'], ['steer', 'Steer (unavailable)']]) { + for (const [mode, text] of [['queue', 'Queue this message'], ['steer', onSteer ? 'Steer active coding turn' : 'Steer (unavailable)']]) { const action = document.createElement('button'); action.type = 'button'; action.textContent = text; @@ -50,7 +50,7 @@ export function createMessageSubmitControls({ button, optionsPanel, getActor, on select.value = mode; select.disabled = pending; for (const action of actions) action.disabled = button.disabled; - const description = mode === 'steer' ? 'Steering unavailable; draft will be retained' : 'Submit separate message'; + const description = mode === 'steer' ? (onSteer ? 'Steer active coding turn' : 'Steering unavailable; draft will be retained') : 'Submit separate message'; presentSubmitMode(button, mode, { label: pending ? 'Submitting message' : description, help: `${description}. Shift-click for the opposite action once. ${optionsName} contains both actions and the shared default.`, @@ -60,6 +60,11 @@ export function createMessageSubmitControls({ button, optionsPanel, getActor, on function activate(event = {}, explicitMode) { if (pending || button.disabled) return; const mode = explicitMode || selectedSubmitMode(getActor(), event); + if (mode === 'steer' && onSteer) { + menu.open = false; + onSteer(); + return; + } if (mode === 'steer') { status.textContent = `Active steering is unavailable on this route. Draft and attachments retained; choose Queue this message in ${optionsName} for separate work.`; onUnavailable(status.textContent); diff --git a/tests/backend/test_codex_von_app_server.py b/tests/backend/test_codex_von_app_server.py index 52cc8b45a..18f757d8b 100644 --- a/tests/backend/test_codex_von_app_server.py +++ b/tests/backend/test_codex_von_app_server.py @@ -207,12 +207,22 @@ def test_exact_worker_launch_uses_owned_transport_and_recovers_structured_result } state = {"task_id": "#V#task", "checkout_prepared": True, "worktree": str(tmp_path)} starts = [] + def start_execution(state): assert state["prepared_at"] <= state["execution_started_at"] assert state["started_at"] == state["execution_started_at"] assert not state.get("active_turn") starts.append(state["execution_started_at"]) - monkeypatch.setattr(worker, "Von", lambda config: SimpleNamespace(start_execution=start_execution)) + + monkeypatch.setattr( + worker, + "Von", + lambda config: SimpleNamespace( + start_execution=start_execution, + publish_steering_target=lambda binding: binding, + withdraw_steering_target=lambda binding: None, + ), + ) monkeypatch.setattr(worker, "referenced_tasks", lambda *a: {"results": []}) monkeypatch.setattr(worker, "stage_file_copy_evidence", lambda *a: {"results": []}) observed = [] diff --git a/tests/backend/test_codex_von_steering.py b/tests/backend/test_codex_von_steering.py index 15ae23db2..54f668f07 100644 --- a/tests/backend/test_codex_von_steering.py +++ b/tests/backend/test_codex_von_steering.py @@ -42,6 +42,9 @@ def receiver(tmp_path, monkeypatch): "src.backend.services.task_dispatch_authority_service.get_db", lambda: db ) api = SimpleNamespace( + publish_steering_target=lambda binding: binding, + withdraw_steering_target=lambda binding: None, + record_steering_delivery=lambda *args: None, task=lambda _: task, native_writer=lambda _: True, messages=SimpleNamespace( @@ -173,3 +176,37 @@ def unavailable(*a, **kw): assert active.state["active_turn"] == active.binding assert active.state["steering_poll_error"]["type"] == "ConnectionError" assert "private details" not in json.dumps(active.state) + + +def test_canonical_receipt_retry_never_replays_guidance(receiver): + active, rows, _ = receiver + message = list(active.pending())[0] + calls = [] + + def store(*args): + calls.append(args) + if len(calls) == 1: + raise ConnectionError("unavailable") + + active.api.record_steering_delivery = store + active.delivered(message, "accepted") + path = inbox.state_path(active.config, rows[0]["message_id"]) + retained = json.loads(path.read_text()) + assert retained["steering_delivery"]["canonical_receipt_error"] == "ConnectionError" + assert not list(active.pending()) + steering.reconcile_receipt(active.api, retained, path) + assert retained["steering_delivery"]["canonical_readback"] + steering.reconcile_receipt(active.api, retained, path) + assert len(calls) == 2 + assert calls[0] == calls[1] + + +def test_late_guidance_has_not_applied_receipt_without_model(receiver): + active, rows, _ = receiver + state = {"context": {"message": rows[0]}} + inbox.launch(active.config, state, 0) + assert state["phase"] == "reporting" + assert state["steering_delivery"] == { + "status": "not_applied", + "binding": active.binding, + } diff --git a/tests/backend/test_coding_agent_steering_service.py b/tests/backend/test_coding_agent_steering_service.py new file mode 100644 index 000000000..4f8dc7ae4 --- /dev/null +++ b/tests/backend/test_coding_agent_steering_service.py @@ -0,0 +1,260 @@ +from copy import deepcopy +from datetime import UTC, datetime, timedelta + +import mongomock +import pytest + +from src.backend.services import coding_agent_steering_service as steering +from src.backend.services import message_service as messages + + +@pytest.fixture +def owner(monkeypatch): + db = mongomock.MongoClient().steering + actor = ["#V#agent"] + binding = dict( + agent_id="#V#agent", + organisation_id="#V#org", + task_id="#V#task", + attempt="attempt-1", + thread_id="thread-1", + turn_id="turn-1", + ) + task = dict( + task_concept_id="#V#task", + status="in_progress", + assignee_concept_id="#V#agent", + created_by_concept_id="#V#owner", + organisation_concept_id="#V#org", + execution_timing={ + "attempts": [ + { + "attempt_id": "attempt-1", + "actor_concept_id": "#V#agent", + "started_at": "2026-09-17T10:00:00Z", + } + ] + }, + ) + monkeypatch.setattr(steering, "get_db", lambda: db) + monkeypatch.setattr(steering, "get_effective_user_concept_id", lambda: actor[0]) + monkeypatch.setattr( + "src.backend.services.task_management_service.get_task", + lambda _: deepcopy(task), + ) + monkeypatch.setattr( + "src.backend.services.task_dispatch_authority_service.get_db", lambda: db + ) + monkeypatch.setattr( + messages, "authorise_direct_message_participants", lambda **_: (True, []) + ) + return db, actor, binding, task + + +def resolve(actor): + actor[0] = "#V#owner" + return steering.resolve( + sender_id="#V#owner", recipient_id="#V#agent", organisation_id="#V#org" + ) + + +def test_owner_publication_readback_admission_and_late_withdrawal(owner): + db, actor, binding, _ = owner + assert steering.publish(binding, delegator_id="#V#owner") == binding + assert resolve(actor) == binding + assert ( + steering.validate_submission( + sender_id="#V#owner", + recipient_ids=["#V#agent"], + organisation_id="#V#org", + target=binding, + ) + == binding + ) + actor[0] = "#V#agent" + replacement = {**binding, "turn_id": "turn-2"} + steering.publish(replacement, delegator_id="#V#owner") + steering.withdraw(binding) + assert resolve(actor) == replacement + with pytest.raises(messages.DirectMessageSteeringUnavailable): + steering.validate_submission( + sender_id="#V#owner", + recipient_ids=["#V#agent"], + organisation_id="#V#org", + target=binding, + ) + + +@pytest.mark.parametrize( + "change", + [ + "cancelled", + "reassigned", + "attempt_replaced", + "ended", + "terminal_unknown_end", + "expired", + "wrong_sender", + "wrong_org", + ], +) +def test_no_admission_after_owner_or_scope_changes(owner, change): + db, actor, binding, task = owner + steering.publish(binding, delegator_id="#V#owner") + if change == "cancelled": + task["status"] = "cancelled" + elif change == "reassigned": + task["assignee_concept_id"] = "#V#other" + elif change == "attempt_replaced": + task["execution_timing"]["attempts"].append({"attempt_id": "attempt-2"}) + elif change == "ended": + task["execution_timing"]["attempts"][0]["ended_at"] = "2026-09-17T10:01:00Z" + elif change == "terminal_unknown_end": + task["execution_timing"]["attempts"][0]["outcome"] = "needs_input" + elif change == "expired": + db[steering.COLLECTION].update_one( + {}, {"$set": {"expires_at": datetime.now(UTC) - timedelta(seconds=1)}} + ) + elif change == "wrong_sender": + task["created_by_concept_id"] = "#V#other" + elif change == "wrong_org": + task["organisation_concept_id"] = "#V#other" + with pytest.raises(messages.DirectMessageSteeringUnavailable): + resolve(actor) + + +def test_sender_cannot_publish_an_owner_binding(owner): + _, actor, binding, _ = owner + actor[0] = "#V#owner" + with pytest.raises(PermissionError): + steering.publish(binding, delegator_id="#V#owner") + + +def test_real_message_service_rechecks_admission_before_persistence(owner, monkeypatch): + db, actor, binding, _ = owner + steering.publish(binding, delegator_id="#V#owner") + resolve(actor) + monkeypatch.setattr(messages, "get_concepts_collection", lambda: db.concepts) + created = [] + monkeypatch.setattr( + messages.ConceptsRepository, + "insert_one", + lambda doc: created.append(deepcopy(doc)), + ) + monkeypatch.setattr(messages, "upsert_text_for_concept", lambda **_: None) + monkeypatch.setattr( + messages, "maybe_launch_direct_message_workflow", lambda **_: None + ) + # Canonical create boundary accepts only the exact current target. + result = messages.create_message( + sender_id="#V#owner", + recipient_ids=["#V#agent"], + org_id="#V#org", + content="Use the bounded existing test.", + metadata={"submit_mode": "steer", "submit_target": binding}, + ) + projection = messages.project_direct_message(result) + assert projection["submit_mode"] == "steer" + assert projection["submit_target"] == binding + assert len(created) == 1 + with pytest.raises(messages.DirectMessageSteeringUnavailable): + messages.create_message( + sender_id="#V#owner", + recipient_ids=["#V#agent"], + org_id="#V#org", + content="Never retarget.", + metadata={ + "submit_mode": "steer", + "submit_target": {**binding, "turn_id": "other"}, + }, + ) + assert len(created) == 1 + + +def test_normal_rest_target_send_replay_and_controller_receipt(owner, monkeypatch): + from flask import Flask + from src.backend.server.routes import message_routes as routes + + db, actor, binding, task = owner + steering.publish(binding, delegator_id="#V#owner") + actor[0] = "#V#owner" + monkeypatch.setattr(routes, "_get_current_user_concept_id", lambda: actor[0]) + monkeypatch.setattr(routes, "_get_current_org_concept_id", lambda *a, **k: "#V#org") + monkeypatch.setattr( + routes, "_authorise_sender_and_recipients_for_org", lambda **_: (True, []) + ) + monkeypatch.setattr(messages, "get_concepts_collection", lambda: db.concepts) + monkeypatch.setattr(messages, "apply_concept_query_filter", lambda query: query) + monkeypatch.setattr( + messages.ConceptsRepository, "insert_one", db.concepts.insert_one + ) + monkeypatch.setattr( + messages.ConceptsRepository, "update_one", db.concepts.update_one + ) + monkeypatch.setattr(messages, "upsert_text_for_concept", lambda **_: None) + monkeypatch.setattr( + messages, "maybe_launch_direct_message_workflow", lambda **_: None + ) + monkeypatch.setattr( + messages, + "get_message_for_user", + lambda mid, uid: db.concepts.find_one({"concept_id": mid}), + ) + monkeypatch.setattr( + "src.backend.services.episode_logging_service.log_episode", lambda **_: None + ) + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(routes.message_bp, url_prefix="/api/messages") + client = app.test_client() + available = client.get( + "/api/messages/steering-target?recipient_id=%23V%23agent&organisation_concept_id=%23V%23org" + ).get_json() + assert available == {"available": True, "submit_target": binding} + payload = { + "recipient_ids": ["#V#agent"], + "organisation_concept_id": "#V#org", + "content": "Harmless exact-turn guidance", + "submit_mode": "steer", + "submit_target": available["submit_target"], + "delivery_idempotency_key": "steering-probe", + } + first = client.post("/api/messages/", json=payload) + assert first.status_code == 201, first.get_json() + message_id = first.get_json()["message_id"] + assert first.get_json()["steering_delivery"]["status"] == "pending" + actor[0] = "#V#agent" + receipt = steering.record_delivery(message_id, binding, "accepted") + assert receipt["consumption_verified"] is False + steering.withdraw(binding) + actor[0] = "#V#owner" + # An ambiguous HTTP retry reconciles its existing message even after completion. + task["status"] = "completed" + replay = client.post("/api/messages/", json=payload) + assert replay.status_code == 200, replay.get_json() + assert replay.get_json()["message_id"] == message_id + assert replay.get_json()["steering_delivery"]["status"] == "accepted" + assert db.concepts.count_documents({}) == 1 + + +def test_missing_canonical_task_is_unavailable_but_store_failure_is_an_error( + owner, monkeypatch +): + from src.backend.services import task_management_service as tasks + + _, actor, binding, _ = owner + steering.publish(binding, delegator_id="#V#owner") + + def missing(_): + raise tasks.TaskNotFoundError("missing") + + monkeypatch.setattr(tasks, "get_task", missing) + with pytest.raises(messages.DirectMessageSteeringUnavailable): + resolve(actor) + + def unavailable(_): + raise ConnectionError("unavailable") + + monkeypatch.setattr(tasks, "get_task", unavailable) + with pytest.raises(ConnectionError): + resolve(actor) diff --git a/tests/browser/messageSubmitMode.cjs b/tests/browser/messageSubmitMode.cjs index 568db5f3a..2f5a4ff47 100644 --- a/tests/browser/messageSubmitMode.cjs +++ b/tests/browser/messageSubmitMode.cjs @@ -9,6 +9,8 @@ const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); const evidence = process.argv[2] || '.run/message-submit-mode'; fs.mkdirSync(evidence, { recursive: true }); let submitted = []; +let available = false; +const target = { agent_id: '#V#worker', organisation_id: '#V#lab', task_id: '#V#fixture_task', attempt: 'fixture-attempt', thread_id: 'fixture-thread', turn_id: 'fixture-turn' }; const server = http.createServer(async (req, res) => { if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); @@ -22,11 +24,12 @@ const server = http.createServer(async (req, res) => { } } res.setHeader('Content-Type', 'application/json'); + if (req.url.startsWith('/api/messages/steering-target?')) return res.end(JSON.stringify({ available, submit_target: available ? target : null })); if (req.url === '/api/messages/' && req.method === 'POST') { let body = ''; for await (const chunk of req) body += chunk; submitted.push(JSON.parse(body)); res.statusCode = 201; - return res.end(JSON.stringify({ success: true, message_id: '#V#fixture_message', submit_mode: 'queue' })); + return res.end(JSON.stringify({ success: true, message_id: '#V#fixture_message', submit_mode: submitted.at(-1).submit_mode })); } res.end(JSON.stringify({ success: true, authenticated: true, current_user_id: '#V#alice', messages: [], profiles: [] })); }); @@ -46,7 +49,7 @@ async function mount(page) { try { const results = []; for (const width of [390, 1280]) { - submitted = []; + submitted = []; available = false; const touch = width < 800; const page = await browser.newPage({ viewport: { width, height: 850 }, hasTouch: touch }); page.setDefaultTimeout(10000); @@ -67,7 +70,7 @@ async function mount(page) { await expect(input).toHaveValue('Keep my multiline\ndraft unchanged.'); await menu.locator('summary')[touch ? 'tap' : 'click'](); await mode.selectOption('steer'); - await expect(button).toHaveAccessibleName(/Steering unavailable/); + await expect(button).toHaveAccessibleName(/Steer active coding turn/); const panelBounds = await menu.locator('.composer-options-panel').boundingBox(); assert(panelBounds.x >= 0 && panelBounds.x + panelBounds.width <= width); await mode.scrollIntoViewIfNeeded(); @@ -92,9 +95,17 @@ async function mount(page) { await input.fill('One'); await input.press('Shift+Enter'); await expect(input).toHaveValue('One\n'); assert.equal(submitted.length, 1); + available = true; + await input.fill('Bound guidance for the active attempt.'); + await button[touch ? 'tap' : 'click'](); + await expect(input).toHaveValue(''); + assert.equal(submitted.length, 2); + assert.equal(submitted[1].submit_mode, 'steer'); + assert.deepEqual(submitted[1].submit_target, target); + await page.screenshot({ path: path.join(evidence, `steer-${width}.png`) }); assert.deepEqual(errors, []); results.push({ width, touch, bounds, panelBounds, savedDefaultAfterReload: true, - unsupportedDraftRetained: true, queuedPayload: submitted[0], noLiveDeliveryClaim: true }); + unsupportedDraftRetained: true, queuedPayload: submitted[0], steeringPayload: submitted[1], noLiveDeliveryClaim: true }); await page.close(); } fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, results }, null, 2)); diff --git a/tests/frontend/messageExchangeView.test.js b/tests/frontend/messageExchangeView.test.js index 4f7597c3b..82c2bb639 100644 --- a/tests/frontend/messageExchangeView.test.js +++ b/tests/frontend/messageExchangeView.test.js @@ -525,3 +525,54 @@ test('a refresh after a disconnected new arrival replaces the window and keeps h [...document.querySelectorAll('button')].find(el => el.textContent === 'Load earlier messages').click(); await flush(); expect(api.postJson.mock.calls.at(-1)[1].before).toBe('new-history'); }); + +test('Steer pins the discovered turn across an ambiguous send retry while Queue remains separate', async () => { + sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#alice' })); + const api = require(base + 'apiService.js'); + const target = { agent_id: '#V#bob', organisation_id: '#V#lab', task_id: '#V#task', attempt: 'attempt-1', thread_id: 'thread-1', turn_id: 'turn-1' }; + api.getJson.mockResolvedValue({ available: true, submit_target: target }); + api.postJsonDetailed.mockRejectedValueOnce(new Error('Connection lost')); + await require(base + 'components/messagePanel.js').openMessageExchange(row('#V#bob')); + const input = document.getElementById('messageInput'); + input.value = 'Use the existing bounded test.'; + document.getElementById('sendMessageBtn').dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + await flush(); + const first = api.postJsonDetailed.mock.calls[0][1]; + expect(first.submit_mode).toBe('steer'); + expect(first.submit_target).toEqual(target); + expect(input.value).toBe('Use the existing bounded test.'); + api.getJson.mockResolvedValue({ available: true, submit_target: { ...target, turn_id: 'replacement' } }); + api.postJsonDetailed.mockRejectedValueOnce(new Error('Still unknown')); + document.getElementById('sendMessageBtn').dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + await flush(); + expect(api.postJsonDetailed.mock.calls[1][1]).toEqual(first); + expect(api.getJson.mock.calls.filter(([url]) => url.startsWith('/api/messages/steering-target'))).toHaveLength(1); + api.postJsonDetailed.mockResolvedValueOnce({ data: { success: true } }); + document.getElementById('sendMessageBtn').click(); await flush(); + const separate = api.postJsonDetailed.mock.calls[2][1]; + expect(separate.submit_mode).toBe('queue'); + expect(separate.submit_target).toBeUndefined(); + expect(separate.delivery_idempotency_key).not.toBe(first.delivery_idempotency_key); +}); + +test('unavailable Mac or idle recipient retains the draft without creating a message', async () => { + const api = require(base + 'apiService.js'); + api.getJson.mockResolvedValue({ available: false, reason: 'Queue remains available.' }); + api.postJsonDetailed.mockClear(); + await require(base + 'components/messagePanel.js').openMessageExchange(row('#V#codex_vscode')); + const input = document.getElementById('messageInput'); input.value = 'Keep this guidance'; + document.getElementById('sendMessageBtn').dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + await flush(); + expect(input.value).toBe('Keep this guidance'); + expect(api.postJsonDetailed).not.toHaveBeenCalled(); +}); + +test('accepted guidance receipt is displayed without claiming model consumption', async () => { + const api = require(base + 'apiService.js'); + const result = response('guidance'); + result.messages[0].concept_data.metadata = { submit_mode: 'steer' }; + result.messages[0].concept_data.steering_delivery = { status: 'accepted', consumption_verified: false }; + api.postJson.mockResolvedValueOnce(result); + await require(base + 'components/messagePanel.js').openMessageExchange(row('#V#bob')); + expect(document.querySelector('.message-steering-status').textContent).toContain('consumption not verified'); +}); From 7ed18e7927bee20f548c37928aab0a1e98546888 Mon Sep 17 00:00:00 2001 From: witbrock Date: Thu, 17 Sep 2026 13:01:19 -0400 Subject: [PATCH 3/4] Document bound steering recovery and retain drafts across target lookup navigation --- docs/engineering/codex_dgx_worker.md | 28 ++++++++++++++----- .../static/js/components/messagePanel.js | 3 ++ tests/frontend/messageExchangeView.test.js | 20 +++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/engineering/codex_dgx_worker.md b/docs/engineering/codex_dgx_worker.md index d941ce2f5..a610bb4c0 100644 --- a/docs/engineering/codex_dgx_worker.md +++ b/docs/engineering/codex_dgx_worker.md @@ -431,13 +431,27 @@ Queue messages stay with ordinary inbox processing. Steering is reserved before dispatch; an acknowledgement is recorded as accepted, never as model consumption. Lost acknowledgements remain uncertain and are not retried or converted to Queue. -This is an unactivated transport implementation, not an enabled public feature. -The message API still rejects Steer and caller-supplied targets. Canonical active -target publication/admission, the Mac owner adapter and live consumption evidence -are outstanding in [PR #692](https://github.com/Strong-AI-Lab/Von/pull/692). -Do not enable message admission from a configuration flag or simulated receipt. -The local protocol tests start only deterministic fixture executables; they do -not establish provider identity, production activation or live consumption. +The existing owner publishes a short-lived operational observation of that exact +binding and its task delegator. The message API resolves it only for the +authenticated delegator in the shared organisation and rechecks the canonical +assignment and current execution attempt before accepting Steer. A browser cannot +publish a target. The owner refreshes its observation at existing checkpoints; +withdrawal removes only its own binding. An unavailable owner leaves the draft +intact and offers Queue explicitly. + +Admission stores the intended binding with the original message. Retries retain +that binding and delivery key, including an ambiguous HTTP result. The owner +records accepted, not-applied or uncertain receipts on the canonical message; +retained receipt recovery never replays guidance. The message pane distinguishes +stored/pending guidance from controller acceptance and does not claim model +consumption from an acknowledgement. + +Production activation and real consumption evidence are separate from these +code paths. The default `exec` owner and the current Mac stdio-only owner publish +no target, so those routes continue to offer Queue. A Mac owner adapter/runtime +choice remains outstanding in [PR #692](https://github.com/Strong-AI-Lab/Von/pull/692). +Local protocol and browser fixtures do not establish provider identity, +production activation or live consumption. The reply prompt permits relevant read-only host/repository inspection. Missing supplied facts do not establish that inspection is unavailable. Conditional diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js index ba192cc38..350788f3b 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -2432,8 +2432,11 @@ async function prepareSteeringPayload(payload, scope) { payload.submit_target = attempt.submitTarget; return true; // Retry the exact original target; never silently retarget. } + const generation = _exchangeGeneration; const params = new URLSearchParams({ recipient_id: payload.recipient_ids[0], organisation_concept_id: payload.organisation_concept_id || '' }); const result = await getJson(`/api/messages/steering-target?${params}`); + if (getDeliveryAttempt(scope)?.key !== attempt?.key + || (scope === COMPOSE_SCOPE_REPLY && generation !== _exchangeGeneration)) return false; if (!result.available || !result.submit_target) { showToast(result.reason || 'Steering unavailable. Draft retained; choose Queue for separate work.', 'warning'); return false; diff --git a/tests/frontend/messageExchangeView.test.js b/tests/frontend/messageExchangeView.test.js index 82c2bb639..053df7a1e 100644 --- a/tests/frontend/messageExchangeView.test.js +++ b/tests/frontend/messageExchangeView.test.js @@ -576,3 +576,23 @@ test('accepted guidance receipt is displayed without claiming model consumption' await require(base + 'components/messagePanel.js').openMessageExchange(row('#V#bob')); expect(document.querySelector('.message-steering-status').textContent).toContain('consumption not verified'); }); + + +test('late target discovery cannot overwrite a different exchange delivery attempt', async () => { + const api = require(base + 'apiService.js'); + api.postJsonDetailed.mockClear(); + const panel = require(base + 'components/messagePanel.js'); + await panel.openMessageExchange(row('#V#bob')); + let found; + api.getJson.mockImplementationOnce(() => new Promise(resolve => { found = resolve; })); + document.getElementById('messageInput').value = 'Bob guidance'; + document.getElementById('sendMessageBtn').dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + await panel.openMessageExchange(row('#V#carol')); + document.getElementById('messageInput').value = 'Carol draft'; + found({ available: true, submit_target: { agent_id: '#V#bob', turn_id: 'old' } }); + await flush(); + expect(api.postJsonDetailed).not.toHaveBeenCalled(); + expect(document.getElementById('messageInput').value).toBe('Carol draft'); + await panel.openMessageExchange(row('#V#bob')); + expect(document.getElementById('messageInput').value).toBe('Bob guidance'); +}); From 88bee67d42660296b8929bdd654ab6d4d52e337c Mon Sep 17 00:00:00 2001 From: witbrock Date: Thu, 17 Sep 2026 13:15:47 -0400 Subject: [PATCH 4/4] Recover transport-only steering receipts through the inbox reporter --- scripts/codex_von_inbox.py | 5 ++-- scripts/codex_von_steering.py | 3 +++ tests/backend/test_codex_von_steering.py | 30 ++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/scripts/codex_von_inbox.py b/scripts/codex_von_inbox.py index 39bb3cbdf..d2b957f35 100644 --- a/scripts/codex_von_inbox.py +++ b/scripts/codex_von_inbox.py @@ -521,10 +521,11 @@ def finish(config, api, state, path): authorise_source(config, api, message) if state.get("steering_delivery"): try: - from .codex_von_steering import reconcile_receipt + from .codex_von_steering import recover_delivery, reconcile_receipt except ImportError: - from codex_von_steering import reconcile_receipt + from codex_von_steering import recover_delivery, reconcile_receipt state["effect_stage"] = "steering_receipt" + recover_delivery(state) reconcile_receipt(api, state, path) # Check again at the effect boundary, including recovered on-disk states. result = state["result"] = validate_result(state["result"], state["context"]) diff --git a/scripts/codex_von_steering.py b/scripts/codex_von_steering.py index 22075bd6d..3b9f3e3b8 100644 --- a/scripts/codex_von_steering.py +++ b/scripts/codex_von_steering.py @@ -20,6 +20,9 @@ def recover_delivery(state): """A process loss never converts reserved guidance into a new request.""" + # A transport receipt is a reply only, with no task action authority. + # Restore this for retained states created before the receipt reporter ran. + state["context"]["tasks"] = [] receipt = state["steering_delivery"] status = receipt["status"] if status == "reserved": diff --git a/tests/backend/test_codex_von_steering.py b/tests/backend/test_codex_von_steering.py index 54f668f07..81800a1e2 100644 --- a/tests/backend/test_codex_von_steering.py +++ b/tests/backend/test_codex_von_steering.py @@ -210,3 +210,33 @@ def test_late_guidance_has_not_applied_receipt_without_model(receiver): "status": "not_applied", "binding": active.binding, } + + +def test_retained_transport_receipt_finishes_through_real_inbox_reporter(receiver): + active, rows, _ = receiver + selected = list(active.pending())[0] + active.delivered(selected, "accepted") + path = inbox.state_path(active.config, rows[0]["message_id"]) + retained = json.loads(path.read_text()) + # Exact live regression: earlier transport states contain only the message. + retained["context"].pop("tasks", None) + sent = [] + + def send(**kwargs): + sent.append(kwargs) + reply = { + "message_id": "#V#reply", + "concept_id": "#V#reply", + "content": kwargs["content"], + } + rows.append(reply) + return SimpleNamespace(message=reply) + + active.api.messages.create_message_idempotently = send + inbox.finish(active.config, active.api, retained, path) + assert retained["phase"] == "done" + assert retained["context"]["tasks"] == [] + assert retained["reply_id"] == "#V#reply" + assert len(sent) == 1 + assert "consumption has not been verified" in sent[0]["content"] + assert sent[0]["reply_to_id"] == selected["message_id"]