fix: a stop typed as a busy correction ends the voice chat on the shared gateway

In Ink's interrupt and steer busy modes, text typed during a running turn
goes to session.redirect / session.steer, not prompt.submit. The typed
stop-phrase check only ran in prompt.submit, so a bare "stop" in a LOCAL
voice chat reached the running agent and voice chat stayed on.

The owner's correct() now makes the same _typed_voice_stop decision (attached
submitter, LOCAL session, voice on, bare stop phrase) after authorization and
before the generation fence, and answers {voice_stopped: true}. The canonical
CanonicalCorrectionResult becomes Ack | CanonicalVoiceStopped (generated TS
and OpenRPC regenerated); the dispatch test proves it through the real
contract parse.

Every client settles that reply instead of treating it as a refusal that
re-sends the text as a prompt (which would admit "stop" as a turn):
- Ink: settleControlReply renders the stop-phrase event and settles the
  item; the running turn's busy state is untouched.
- Desktop: redirectPrompt drops the optimistic bubble and reports accepted,
  so the composer does not re-queue it.
- ACP: /steer reports the stop-phrase notice and does not send a prompt.

Greptile review on #141508.

Refs #134889 (TUI-26)
This commit is contained in:
Teknium committed 2026-10-19 06:54:34 -07:00
1 parent 4ffb3580b0
commit dfb4ecd911
12 files changed
+183 -37

No files matched your search

+5
View File
@@ -177,6 +177,11 @@ async def _steer(agent, session_id, text):
if str(exc) == 'stale_generation':
return None
raise
if result.get('voice_stopped') is True:
# TUI-26: the guidance was a bare stop phrase that ended this LOCAL session's voice chat;
# no turn saw it, and sending it on as a prompt would admit ``stop`` as a turn.
from agent.i18n import t
return t('cli.voice.stop_phrase_typed')
if result.get('status') != 'queued':
return None
return f"⏩ Steer queued for the active turn: {text[:80]}{'...' if len(text) > 80 else ''}"
@@ -888,6 +888,14 @@ export function usePromptActions({
{ session_id: id, text }
)
// TUI-26: a bare stop phrase typed mid-turn into a LOCAL voice chat ended that chat on
// the owner and reached no turn. Settled (nothing to re-queue), and no bubble stays.
if (result?.voice_stopped === true) {
discardOptimisticMessage()
return true
}
if (result?.status === 'redirected') {
if (mode === 'interrupt') {
updateSessionState(id, state => {
@@ -541,3 +541,20 @@ it.each(rebuiltRuntimeCases)(
expect(requestGatewayForProfile).not.toHaveBeenCalled()
}
)
it.each(['interrupt', 'steer'] as const)(
'a %s correction the owner consumed as a voice stop settles with no user bubble (TUI-26)',
async mode => {
seed()
vi.mocked(requestGatewayForAgent).mockResolvedValue({ voice_stopped: true })
await act(async () => {
expect(await handle.actions.redirectPrompt('stop', mode)).toBe(true)
})
const rows = [...handle.cache.sessionStateByRuntimeIdRef.current.values()]
.flatMap(state => state.messages)
.filter(message => message.role === 'user')
expect(rows).toHaveLength(0)
}
)
+2
View File
@@ -95,6 +95,8 @@ export interface SessionSteerResponse {
export interface SessionRedirectResponse {
status?: 'redirected' | 'queued' | 'rejected'
text?: string
/** TUI-26: the text was a bare stop phrase that ended the LOCAL voice chat; no turn saw it. */
voice_stopped?: boolean
}
export interface SessionTitleResponse {
@@ -5070,12 +5070,18 @@ export interface CanonicalCorrectionParams {
execution_generation: number
text: string
}
export interface CanonicalCorrectionResult {
/** ``session.steer`` / ``session.redirect``: the correction verdict, or ``{voice_stopped: true}`` when the busy-time text was a bare stop phrase that ended the LOCAL voice chat. */
export type CanonicalCorrectionResult = CanonicalCorrectionAck | CanonicalVoiceStopped
export interface CanonicalCorrectionAck {
status: 'queued' | 'redirected' | 'rejected'
text: string
execution_generation: number
authority_epoch: number
}
/** A bare stop phrase typed while the session's voice chat is on: the chat ended, nothing was admitted (TUI-26). */
export interface CanonicalVoiceStopped {
voice_stopped: true
}
export interface CanonicalEventsParams {
profile?: string | null
session_id: string
@@ -5141,10 +5147,6 @@ export interface CanonicalModelOverride {
}
/** ``prompt.submit``: the admission receipt, or ``{voice_stopped: true}``. */
export type CanonicalSubmitResult = AdmissionStatus | CanonicalVoiceStopped
/** A bare stop phrase typed while the session's voice chat is on: the chat ended, nothing was admitted (TUI-26). */
export interface CanonicalVoiceStopped {
voice_stopped: true
}
export interface CanonicalApprovalParams {
profile?: string | null
session_id: string
+41 -29
View File
@@ -8483,6 +8483,40 @@
"title": "CanonicalControlParams",
"type": "object"
},
"CanonicalCorrectionAck": {
"additionalProperties": false,
"properties": {
"status": {
"enum": [
"queued",
"redirected",
"rejected"
],
"title": "Status",
"type": "string"
},
"text": {
"title": "Text",
"type": "string"
},
"execution_generation": {
"title": "Execution Generation",
"type": "integer"
},
"authority_epoch": {
"title": "Authority Epoch",
"type": "integer"
}
},
"required": [
"status",
"text",
"execution_generation",
"authority_epoch"
],
"title": "CanonicalCorrectionAck",
"type": "object"
},
"CanonicalCorrectionParams": {
"additionalProperties": false,
"properties": {
@@ -8520,38 +8554,16 @@
"type": "object"
},
"CanonicalCorrectionResult": {
"additionalProperties": false,
"properties": {
"status": {
"enum": [
"queued",
"redirected",
"rejected"
],
"title": "Status",
"type": "string"
"anyOf": [
{
"$ref": "#/components/schemas/CanonicalCorrectionAck"
},
"text": {
"title": "Text",
"type": "string"
},
"execution_generation": {
"title": "Execution Generation",
"type": "integer"
},
"authority_epoch": {
"title": "Authority Epoch",
"type": "integer"
{
"$ref": "#/components/schemas/CanonicalVoiceStopped"
}
},
"required": [
"status",
"text",
"execution_generation",
"authority_epoch"
],
"title": "CanonicalCorrectionResult",
"type": "object"
"description": "``session.steer`` / ``session.redirect``: the correction verdict, or ``{voice_stopped: true}``\nwhen the busy-time text was a bare stop phrase that ended the LOCAL voice chat.",
"title": "CanonicalCorrectionResult"
},
"CanonicalCreateParams": {
"additionalProperties": false,
+6
View File
@@ -177,6 +177,12 @@ async def correct(connection, ref, params, *, verb):
or type(params.get('execution_generation')) is not int):
raise RuntimeStoreError('invalid_params')
authorize(connection, ref, params, 'session:control')
# TUI-26: text typed during a running turn in Ink's interrupt/steer busy modes arrives here, not
# at prompt.submit. A bare stop phrase in a LOCAL voice chat ends the chat and reaches no agent,
# whatever generation is running.
voice_stop = getattr(connection, '_typed_voice_stop', None)
if voice_stop is not None and await voice_stop(ref, params):
return {'voice_stopped': True}
authority = connection.authority
generation = params['execution_generation']
live = authority.sessions[ref.session_id]
@@ -53,6 +53,27 @@ async def test_steer_reaches_this_editors_running_turn():
agent._conn.session_update.await_args.kwargs['update'].content.text
@pytest.mark.asyncio
async def test_a_steer_that_ended_the_voice_chat_is_reported_and_never_resent_as_a_prompt():
"""TUI-26: ``/steer stop`` in a LOCAL voice chat ends the chat on the owner (``voice_stopped``);
sent on as a prompt it would admit ``stop`` as a turn."""
from agent.i18n import t
calls = []
async def rpc(method, **params):
calls.append(method)
if method == 'prompt.receipt':
return _receipt('started', 9)
return {'voice_stopped': True}
agent = _agent(rpc)
agent._admissions['s'] = 'mine'
response = await agent.prompt([acp.text_block('/steer stop')], 's')
assert response.stop_reason == 'end_turn'
assert calls == ['prompt.receipt', 'session.steer']
assert t('cli.voice.stop_phrase_typed') in agent._conn.session_update.await_args.kwargs['update'].content.text
@pytest.mark.asyncio
async def test_steer_while_this_editors_prompt_waits_behind_another_clients_turn_is_queued():
calls = []
@@ -101,6 +101,33 @@ async def test_a_messaging_lane_viewer_typing_stop_is_admitted_untouched(voice_o
assert _ledger(voice_owner.db) == ['r1']
@pytest.mark.asyncio
@pytest.mark.parametrize('method', ['session.redirect', 'session.steer'])
async def test_a_stop_typed_as_a_busy_correction_ends_the_voice_chat_and_reaches_no_agent(voice_owner, method):
"""Ink's ``interrupt``/``steer`` busy modes send text typed during a running turn through
``session.redirect``/``session.steer``, not ``prompt.submit``: the same stop decision covers them."""
voice_owner.runner._voice_mode['s'] = 'voice_only'
agent_calls = []
voice_owner.authority.steer_agent = lambda *a: agent_calls.append(a) or (None, True)
with voice_owner.db:
reply = await _call(_connection(voice_owner.authority), method, text='stop', execution_generation=0)
assert reply.get('result') == {'voice_stopped': True}, reply
assert voice_owner.runner._voice_mode['s'] == 'off'
assert agent_calls == [], 'the stop phrase must never reach the running turn'
@pytest.mark.asyncio
async def test_other_busy_corrections_still_reach_the_turn(voice_owner):
voice_owner.runner._voice_mode['s'] = 'voice_only'
voice_owner.authority.steer_agent = lambda *a: (None, True)
voice_owner.authority.check_approval_generation = lambda *a: None
with voice_owner.db:
reply = await _call(_connection(voice_owner.authority), 'session.steer', text='stop doing that',
execution_generation=0)
assert reply['result']['status'] == 'queued', reply
assert voice_owner.runner._voice_mode['s'] == 'voice_only'
def test_the_canonical_contract_declares_the_voice_stopped_reply():
from tui_gateway.contracts.registry import CANONICAL_METHODS
contract = CANONICAL_METHODS['prompt.submit']
+6 -1
View File
@@ -261,13 +261,18 @@ class CanonicalCorrectionParams(CanonicalControlParams):
text: str
class CanonicalCorrectionResult(Result):
class CanonicalCorrectionAck(Result):
status: Literal['queued', 'redirected', 'rejected']
text: str
execution_generation: int
authority_epoch: int
class CanonicalCorrectionResult(RootModel[CanonicalCorrectionAck | CanonicalVoiceStopped]):
"""``session.steer`` / ``session.redirect``: the correction verdict, or ``{voice_stopped: true}``
when the busy-time text was a bare stop phrase that ended the LOCAL voice chat."""
class CanonicalApprovalParams(CanonicalControlParams):
prompt_id: str
choice: str
@@ -300,6 +300,34 @@ describe('submissionCore.submitPrompt — typed voice stop on the shared gateway
expect(publishLocalEvent).not.toHaveBeenCalled()
})
it.each(['session.redirect', 'session.steer'] as const)(
'a stop typed as a %s busy correction settles as the stop-phrase event, not a rejected correction',
async controlMethod => {
const request = vi.fn(async () => ({ voice_stopped: true }))
const publishLocalEvent = vi.fn()
const gw = { isCanonical: true, publishLocalEvent, request } as unknown as GatewayClient
const sys = vi.fn()
const settle = vi.fn()
patchUiState({ busy: true, status: 'running…' })
submitPrompt('stop', makeDeps(gw, { sys }), true, undefined, {
behindTurn: true,
destination: captureDestination(),
queueItem: { controlMethod, executionGeneration: 3, settle, submissionId: 'c1', text: 'stop' } as never,
skipDetectDrop: true
})
await vi.waitFor(() => expect(settle).toHaveBeenCalledWith(true))
expect(publishLocalEvent).toHaveBeenCalledWith({
payload: { stop_phrase: true, typed: true },
session_id: 'sess-1',
type: 'voice.transcript'
})
expect(sys).not.toHaveBeenCalled()
expect(getUiState().busy).toBe(true)
}
)
it('a stop typed behind a running turn (queue mode) ends voice but leaves that turn running', async () => {
const request = vi.fn(async () => ({ voice_stopped: true }))
const publishLocalEvent = vi.fn()
+15 -2
View File
@@ -139,8 +139,21 @@ function settleControlReply(
item: QueueItem,
r: PromptSubmitResponse,
deps: SubmitPromptDeps,
focused: () => boolean
focused: () => boolean,
sid: string
): void {
// A bare stop phrase typed as a busy correction ended the voice chat and reached
// no turn (TUI-26): settled, rendered as the stop-phrase event, the turn untouched.
if (r?.voice_stopped === true) {
item.settle?.(true)
if (deps.gw.isCanonical) {
deps.gw.publishLocalEvent({ payload: { stop_phrase: true, typed: true }, session_id: sid, type: 'voice.transcript' })
}
return
}
const control = r as PromptSubmitResponse & { execution_generation?: number }
const accepted =
@@ -349,7 +362,7 @@ export function submitPrompt(
)
.then(r => {
if (item?.controlMethod) {
settleControlReply(item, r, deps, focused)
settleControlReply(item, r, deps, focused, sid)
return
}