mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
4ffb3580b0
commit
dfb4ecd911
12 files changed
+183
-37
No files matched your search
@@ -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)
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user