From b084d1b5f8b420cd561946df92c61169a05680cb Mon Sep 17 00:00:00 2001 From: Simon Qi Date: Thu, 17 Sep 2026 11:31:12 +1200 Subject: [PATCH 1/5] Answer a held question with allow + answers, not a denial (#371) A voice or Telegram answer to AskUserQuestion now rides the held PermissionRequest hook as {"behavior":"allow","updatedInput":{..., "answers":{...}}}, measured live on Claude Code 2.1.273. The terminal shows "User answered Claude's questions" instead of a red denial, and the model reads "The user answered" instead of a failed tool call. - The engine's verdict frame carries the words as `answer`; the hook, which holds the call's input, builds `answers` (approval.answered_input). - Canonicalisation moves to the hook, per question, and answers with the label as written (including a "(recommended)" mark). - One utterance answers every question in a multi-question call. - A question with no text to key by is refused before anything is written. - Permission verdicts are unchanged; `Ruling:` and the wire `message` retire. - ADR 0015 gains an amendment superseding the 2026-09-10 one. Closes #371 Co-Authored-By: Claude Opus 5 --- ...-a-held-question-opens-the-reply-window.md | 40 ++++ .../adapters/agent/claude/approval.py | 180 ++++++++++----- .../adapters/agent/claude/approval_hook.py | 38 +++- .../adapters/agent/claude/stop_analysis.py | 4 +- tests/test_claude_approval.py | 212 ++++++++++++++++-- 5 files changed, 384 insertions(+), 90 deletions(-) diff --git a/docs/adr/0015-a-held-question-opens-the-reply-window.md b/docs/adr/0015-a-held-question-opens-the-reply-window.md index ee5ade6..0a90e68 100644 --- a/docs/adr/0015-a-held-question-opens-the-reply-window.md +++ b/docs/adr/0015-a-held-question-opens-the-reply-window.md @@ -85,3 +85,43 @@ misleads is the next person to open this repository, which is who this amendment Unchanged: the framed denial itself (#128 — an answer is not a fourth `ApprovalVerdict` kind), the canonicalisation boundary above, and `approval_ack` as the only positive delivery proof. Nothing here may be re-graded on what a terminal renders. + +## Amendment 2026-09-17: an answer is an `allow` carrying `answers` + +Source: [#371](https://github.com/Simon-AI-coding/GPT-VoiceCoding/issues/371). Supersedes the +2026-09-10 amendment, and the "framed denial" wording in this ADR's opening and canonicalisation +paragraphs. + +**There is another shape, and it is measured live.** On Claude Code 2.1.273 a throwaway +`PermissionRequest` hook answered an `AskUserQuestion` with `{"behavior": "allow", "updatedInput": +{...the call's input..., "answers": {"": ""}}}`. The hooks reference documents +this: of `answers` it says *"Claude doesn't set this field; supply it via `updatedInput` to answer +programmatically"*. The transcript records `hook_permission_decision: allow`, the terminal prints +`User answered Claude's questions` and `Allowed by PermissionRequest hook` with no error styling, and +the model reads `The user answered: …` — or `Your questions have been answered: …` when every answer +is an offered label — and acts on free text as readily as on a label. The rejection text the +previous amendment relied on names the two *behaviours*, and an `allow` with `updatedInput` is one +of them. A `strict` permission kind (presumably `--restricted`, not verified) refuses any changed +input, and the dialog then stays on screen for the human. + +So the held hook now answers with that shape. The engine's verdict frame carries the user's words +as `answer` beside `verdict: "allow"`; the hook, which already holds the call's input from its +stdin, builds `answers` (`approval.answered_input`). `Ruling: ` and the frame it named are retired: +there is no error wrapper left for a prefix to be read under. + +- **Canonicalisation moves to the hook** and stays inside the adapter. Per question, words that + match one of that question's labels — ignoring case, spacing and the `(recommended)` mark — + become the label exactly as the Session wrote it, which is the comparison Claude Code makes + before calling the answers a plain choice. Anything else is sent verbatim. +- **One utterance answers every question in a call.** The words carry no per-question split, so + each question with text receives them whole, canonicalised against its own labels. A call with no + question text has no key to put the words under; the engine refuses that Relay before writing, and + the dialog on screen keeps the question. +- **Permission verdicts are unchanged**: plain `allow`, or `deny` with `DENIED_BY_VOICE`, and never + `updatedInput` or `updatedPermissions`. The hook ignores an `answer` on any frame but an `allow` + for an `AskUserQuestion`, and prints nothing for it. + +Unchanged: an answer is still not a fourth `ApprovalVerdict` kind at the seam (#128), +`approval_ack` is still the only positive delivery proof, and a late answer is still refused rather +than falling through to the inbox. `PROVEN_AGAINST_VERSION` stays where it is; this was a measurement +of the answer shape, not a re-measurement of the whole route. diff --git a/src/gpt_voicecoding/adapters/agent/claude/approval.py b/src/gpt_voicecoding/adapters/agent/claude/approval.py index 0c832fe..edeb5c8 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/approval.py +++ b/src/gpt_voicecoding/adapters/agent/claude/approval.py @@ -21,8 +21,8 @@ At the seam, permissions use the Approval Relay's `allow`, `deny`, or `ask`. Questions use the Answer Relay's ordinary words; the Claude adapter selects this hook instead of the inbox while the exact prompt remains parked. At the wire, -that answer is a denial whose framed message Claude consumes as the question's -tool result. `ask` is the one verdict said by saying nothing. +that answer is an `allow` whose `updatedInput` carries the words as the call's +`answers` (#371). `ask` is the one verdict said by saying nothing. **The wire keeps the only clock (ADR 0015, amended by #191).** A parked entry lives exactly as long as the hook holding it: Claude Code ends that hook at the @@ -126,24 +126,90 @@ # `PROVEN_AGAINST_VERSION` is deliberately not moved by this: that pin covers the # whole route including the live `--plugin-dir` load, and a string read is not # that measurement. -def hook_decision(verdict: ApprovalVerdict, *, message: str | None = None) -> dict[str, Any] | None: +# +# **A third shape, measured live on 2.1.273** (#371): `allow` with an +# `updatedInput` is accepted, and re-checked against the deny / ask rules. For +# `AskUserQuestion` that is the documented way to answer: the hooks reference +# says of `answers` *"supply it via `updatedInput` to answer programmatically"*. +# The terminal then shows `User answered Claude's questions` and the model reads +# `The user answered`. A `strict` permission kind (presumably `--restricted`, not +# verified) refuses any changed input, and the dialog then stays on screen. +def hook_decision( + verdict: ApprovalVerdict, *, updated_input: Mapping[str, Any] | None = None +) -> dict[str, Any] | None: """What the hook prints for one verdict, or `None` when it prints nothing. - Neither `updatedInput` nor `updatedPermissions` ever appears. The first would - rewrite the call the user was asked about, so their yes would be a yes to - something else; the second is a session-scoped rule, and the locked ceiling - for a spoken grant is one call. + `updatedPermissions` never appears: it is a session-scoped rule, and the + locked ceiling for a spoken grant is one call. + + `updatedInput` appears only on an `allow` that answers a question, built by + `answered_input`. On a permission it would rewrite the call the user was + asked about, so their yes would be a yes to something else. On + `AskUserQuestion` the rewrite *is* the answer: `answers` is the one field + the model never sets. """ if verdict is ApprovalVerdict.ASK: return None - decision: dict[str, Any] = ( - {"behavior": ALLOW_BEHAVIOR} - if verdict is ApprovalVerdict.ALLOW - else {"behavior": DENY_BEHAVIOR, "message": message or DENIED_BY_VOICE} - ) + decision: dict[str, Any] + if verdict is ApprovalVerdict.ALLOW: + decision = {"behavior": ALLOW_BEHAVIOR} + if updated_input is not None: + decision["updatedInput"] = dict(updated_input) + else: + decision = {"behavior": DENY_BEHAVIOR, "message": DENIED_BY_VOICE} return {"hookSpecificOutput": {"hookEventName": HOOK_EVENT, "decision": decision}} +def answered_input(payload: Mapping[str, Any], words: str) -> dict[str, Any] | None: + """One `AskUserQuestion` call's input with the user's words as its `answers`. + + `None` for anything that is not a question with at least one question text + to answer under, because Claude Code keys `answers` by that exact text. + + **One utterance answers every question in the call.** A spoken or typed reply + arrives as one string with no per-question split, so each question gets it + whole and the model reads it against each. Per question, words that match + one of *that* question's labels (ignoring case, spacing and the + `(recommended)` mark) become the label exactly as written. Claude Code + treats an all-label answer as a plain choice (`Your questions have been + answered`); anything else is delivered verbatim and read as free text + (`The user answered`). + """ + if payload.get(TOOL_NAME_FIELD) != stop_analysis.QUESTION_TOOL: + return None + tool_input = payload.get(TOOL_INPUT_FIELD) + questions = tool_input.get(QUESTIONS_FIELD) if isinstance(tool_input, Mapping) else None + if not isinstance(questions, list): + return None + answers: dict[str, str] = {} + for question in questions: + if not isinstance(question, Mapping): + continue + text = question.get(QUESTION_FIELD) + if isinstance(text, str) and text.strip(): + answers[text] = _as_offered(question.get(OPTIONS_FIELD), words) + if not answers: + return None + return {**tool_input, ANSWERS_FIELD: answers} + + +def _as_offered(options: Any, words: str) -> str: + """The offered label these words name, as written, or the words unchanged.""" + spoken = _comparable(words) + for option in options if isinstance(options, list) else (): + label = option.get(LABEL_FIELD) if isinstance(option, Mapping) else None + if not isinstance(label, str) or not label.strip(): + continue + unmarked, _ = stop_analysis.split_recommendation(label.strip()) + if spoken in (_comparable(label), _comparable(unmarked)): + return label + return words + + +def _comparable(text: str) -> str: + return " ".join(text.split()).casefold() + + # -- the wire between the hook process and this engine ------------------- #: One request in, one answer back, and nothing else is speakable on this socket. @@ -190,34 +256,18 @@ def hook_decision(verdict: ApprovalVerdict, *, message: str | None = None) -> di TOOL_NAME_FIELD: Final = "tool_name" TOOL_INPUT_FIELD: Final = "tool_input" VERDICT_FIELD: Final = "verdict" -MESSAGE_FIELD: Final = "message" REASON_FIELD: Final = "reason" -#: Claude consumes a denied `AskUserQuestion` call's message as the tool result, -#: and a denial is the only shape that carries one — re-measured on 2.1.267 for -#: #328, whose own rejection text still enumerates `{"behavior": "allow"}` and -#: `{"behavior": "deny", "message": ...}` and nothing else. So this route's -#: success is rendered as its failure: the terminal prints the answer in error -#: styling, and the model receives it wrapped as a failed tool result. -#: -#: **This prefix is the whole of the remedy that reaches the model.** The framing -#: around it belongs to Claude Code; the sentence inside it is ours. It is what -#: the model reads first under that wrapper, so it is what decides whether the -#: answer is acted on or discarded as an error to route around. -#: -#: `Ruling` earns the slot three ways. It is **positive** — a phrasing like *this -#: is not an error* steers by prohibition, which makes the forbidden reading more -#: available rather than less. It is **already this repo's word**, carried by the -#: ADRs and by #128's own ruling, so the model meets it in the code, the docs and -#: the tracker alike. And it is **narrow**: it settles the question that was -#: asked and claims nothing about the exchange, where *final* would suppress the -#: follow-up a Session is sometimes right to ask. -#: -#: It names neither the user nor this product, and the frame is no thinner for -#: it: both are already carried, by the `AskUserQuestion` call this answers and -#: by the channel the words arrived on. Its one remaining job is to be read -#: correctly under an error tag, which is the job it is worded for. -QUESTION_ANSWER_PREFIX: Final = "Ruling: " +#: The user's words on a verdict that answers a question. The engine sends them +#: as spoken; the hook holds the call's input and builds `answers` from it. +ANSWER_FIELD: Final = "answer" + +#: `AskUserQuestion`'s own input fields, as its tool schema names them. +QUESTIONS_FIELD: Final = "questions" +QUESTION_FIELD: Final = "question" +OPTIONS_FIELD: Final = "options" +LABEL_FIELD: Final = "label" +ANSWERS_FIELD: Final = "answers" #: The registration's own fields. `transcript_path` is the one that earns this #: hook its place (#71): Claude Code's own registry does not carry it, and it @@ -347,6 +397,7 @@ class _Waiting: "acknowledged", "answered", "gone", + "keyed", "permission", "question", "target", @@ -360,6 +411,7 @@ def __init__( *, permission: ApprovalRequest | None = None, question: WaitingFor | None = None, + keyed: bool = False, ) -> None: if (permission is None) == (question is None): raise ValueError("a parked hook is exactly one permission or question") @@ -370,6 +422,9 @@ def __init__( #: permission. Parsed once, here, when the payload arrives: two parses of #: one message are two answers that can disagree. self.question = question + #: Whether the question carries a text `answers` can be keyed by, read + #: off the same payload by the same builder the hook will use. + self.keyed = keyed #: Set once a verdict has been written to this hook. It is what tells the #: connection's own task that the end it is about to see is an ordinary #: goodbye rather than a human winning the race. @@ -570,33 +625,36 @@ async def answer( approval_id, verdict, request_id=request_id, - message=None, + answer=None, question_only=False, ) async def answer_question( self, question_id: str, words: str, *, request_id: RequestId ) -> DeliveryReceipt: - """Carry the user's words into the exact question hook still parked.""" + """Carry the user's words into the exact question hook still parked. + + The words travel as spoken; the hook matches them to the offered labels + (`answered_input`), because it holds the call's input and this does not. + A question with no text is refused before anything is written, since + `answers` has no key to put the words under, and the dialog on screen + keeps it. + """ waiting = self._waiting.get(question_id) if waiting is None or waiting.question is None: if question_id in self._answered_elsewhere: return _failed(request_id, "that question was answered elsewhere") return _failed(request_id, f"no question {question_id} is answerable on this Session") - collapsed = " ".join(words.split()) - canonical = next( - ( - option.text - for option in waiting.question.options - if " ".join(option.text.split()).casefold() == collapsed.casefold() - ), - words, - ) + if not waiting.keyed: + return _failed( + request_id, + "the question on screen has no text to answer under; answer it in the terminal", + ) return await self._answer( question_id, - ApprovalVerdict.DENY, + ApprovalVerdict.ALLOW, request_id=request_id, - message=QUESTION_ANSWER_PREFIX + canonical, + answer=words, question_only=True, ) @@ -606,10 +664,10 @@ async def _answer( verdict: ApprovalVerdict, *, request_id: RequestId, - message: str | None, + answer: str | None, question_only: bool, ) -> DeliveryReceipt: - """Pop one parked hook first, then carry one framed wire decision.""" + """Pop one parked hook first, then carry one wire decision.""" waiting = self._waiting.pop(approval_id, None) if waiting is None: if approval_id in self._answered_elsewhere: @@ -632,7 +690,7 @@ async def _answer( reason="handed back to the on-screen dialog, which still holds it", ) try: - await self._write_verdict(waiting, verdict, message=message) + await self._write_verdict(waiting, verdict, answer=answer) except (OSError, ConnectionError) as broken: self._answered_elsewhere.add(approval_id) if verdict is ApprovalVerdict.ASK: @@ -653,12 +711,12 @@ async def _answer( return DeliveryReceipt(request_id=request_id, outcome=Delivery.DELIVERED) async def _write_verdict( - self, waiting: _Waiting, verdict: ApprovalVerdict, *, message: str | None = None + self, waiting: _Waiting, verdict: ApprovalVerdict, *, answer: str | None = None ) -> None: """Put one verdict on one hook's connection. Raises if it did not go.""" frame: dict[str, Any] = {TYPE_FIELD: VERDICT_TYPE, VERDICT_FIELD: str(verdict)} - if message is not None: - frame[MESSAGE_FIELD] = message + if answer is not None: + frame[ANSWER_FIELD] = answer await self._reply(waiting.writer, frame) waiting.answered.set() @@ -729,7 +787,13 @@ async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWrite else: approval_id = str(uuid.uuid4()) request = request_from(payload, target=target, approval_id=approval_id) - waiting = _Waiting(target, writer, permission=request, question=question) + waiting = _Waiting( + target, + writer, + permission=request, + question=question, + keyed=answered_input(payload, "") is not None, + ) self._waiting[approval_id] = waiting if question is not None: self._released_questions.pop(target, None) diff --git a/src/gpt_voicecoding/adapters/agent/claude/approval_hook.py b/src/gpt_voicecoding/adapters/agent/claude/approval_hook.py index ba46e71..15cecae 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/approval_hook.py +++ b/src/gpt_voicecoding/adapters/agent/claude/approval_hook.py @@ -68,8 +68,8 @@ from gpt_voicecoding.adapters.agent.claude.approval import ( ACK_TYPE, + ANSWER_FIELD, MAX_HOOK_REQUEST_BYTES, - MESSAGE_FIELD, PROMPT_ID_FIELD, REQUEST_TYPE, SESSION_ID_FIELD, @@ -78,6 +78,7 @@ TYPE_FIELD, VERDICT_FIELD, VERDICT_TYPE, + answered_input, hook_decision, ) from gpt_voicecoding.adapters.agent.claude.bootstrap import ( @@ -119,12 +120,13 @@ def request_for(payload: Mapping[str, Any]) -> dict[str, Any] | None: def ask_engine( request: dict[str, Any], *, path: Path, dial_timeout: float ) -> tuple[ApprovalVerdict, str | None]: - """One dialog out, one verdict back. Every failure answers `ASK`. + """One dialog out, one verdict back, with the answer words for a question. - A refusal, a closed socket, a reply about something else and a reply that is - not JSON all mean the same thing here — nobody is going to answer this by - voice — and they mean it in the one direction that cannot hurt: back to the - human, who is looking at the dialog right now. + Every failure answers `ASK`. A refusal, a closed socket, a reply about + something else and a reply that is not JSON all mean the same thing here — + nobody is going to answer this by voice — and they mean it in the one + direction that cannot hurt: back to the human, who is looking at the dialog + right now. """ try: connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -182,7 +184,11 @@ def _one_line(connection: socket.socket) -> bytes: def _verdict_in(line: bytes) -> tuple[ApprovalVerdict, str | None]: - """The engine's answer, or `ASK` for anything that is not exactly one.""" + """The engine's verdict and the user's answer words, or `ASK` for anything else. + + Words ride only an `allow`: an answer is the one verdict that rewrites the + call's input, and any other pairing is a frame the engine never builds. + """ if not line.strip(): return ApprovalVerdict.ASK, None try: @@ -195,8 +201,12 @@ def _verdict_in(line: bytes) -> tuple[ApprovalVerdict, str | None]: verdict = ApprovalVerdict(document.get(VERDICT_FIELD)) except ValueError: return ApprovalVerdict.ASK, None - message = document.get(MESSAGE_FIELD) - return verdict, message if isinstance(message, str) else None + if ANSWER_FIELD not in document: + return verdict, None + answer = document[ANSWER_FIELD] + if not isinstance(answer, str) or verdict is not ApprovalVerdict.ALLOW: + return ApprovalVerdict.ASK, None + return verdict, answer def decide(payload: Mapping[str, Any], environ: Mapping[str, str]) -> dict[str, Any] | None: @@ -211,8 +221,14 @@ def decide(payload: Mapping[str, Any], environ: Mapping[str, str]) -> dict[str, request = request_for(payload) if request is None: return None - verdict, message = ask_engine(request, path=path, dial_timeout=dial_timeout_in(environ)) - return hook_decision(verdict, message=message) + verdict, answer = ask_engine(request, path=path, dial_timeout=dial_timeout_in(environ)) + if answer is None: + return hook_decision(verdict) + # An answer the dialog cannot take is not a yes to anything else. + answered = answered_input(payload, answer) + if answered is None: + return None + return hook_decision(verdict, updated_input=answered) def main(argv: list[str] | None = None) -> int: diff --git a/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py b/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py index 28e6f61..e6f22f0 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py +++ b/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py @@ -484,7 +484,7 @@ def _groups(tool_input: Any) -> list[_QuestionGroup]: continue label = option.get("label") if isinstance(label, str) and label.strip(): - text, is_recommended = _split_recommendation(label.strip()) + text, is_recommended = split_recommendation(label.strip()) raw_description = option.get("description") description = ( raw_description.strip() @@ -507,7 +507,7 @@ def _groups(tool_input: Any) -> list[_QuestionGroup]: return groups -def _split_recommendation(label: str) -> tuple[str, bool]: +def split_recommendation(label: str) -> tuple[str, bool]: """The option's spoken words, and whether it is the marked recommendation.""" if label.lower().endswith(RECOMMENDED_MARKER): text = label[: -len(RECOMMENDED_MARKER)].strip() diff --git a/tests/test_claude_approval.py b/tests/test_claude_approval.py index 79f3ed0..cd871cb 100644 --- a/tests/test_claude_approval.py +++ b/tests/test_claude_approval.py @@ -30,6 +30,7 @@ import shutil import socket from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any @@ -171,6 +172,33 @@ async def hook_in_flight( return task +def decide_against_one_frame( + root: Path, payload: dict[str, Any], frame: dict[str, Any] +) -> dict[str, Any] | None: + """Run the real hook against an engine that answers with exactly `frame`. + + A stand-in engine rather than the listener, because the listener never + writes a malformed frame — and the hook must still read one as silence. + """ + path = root / "fake-engine.sock" + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(str(path)) + server.listen(1) + + def engine() -> None: + connection, _ = server.accept() + with connection: + connection.recv(MAX_HOOK_REQUEST_BYTES) + connection.sendall(json.dumps(frame).encode("utf-8") + b"\n") + connection.recv(4096) + + with server, ThreadPoolExecutor(max_workers=1) as pool: + served = pool.submit(engine) + decision = decide(payload, environment(path, root)) + served.result(timeout=2.0) + return decision + + async def park_from_claude_code( listener: ApprovalListener, payload: dict[str, Any] ) -> asyncio.StreamWriter: @@ -219,20 +247,23 @@ def test_deny_says_why_so_the_session_can_report_it(self) -> None: assert inner["behavior"] == DENY_BEHAVIOR assert inner["message"].strip() - def test_a_question_answer_is_denial_prose_with_the_framed_words(self) -> None: - assert hook_decision( - ApprovalVerdict.DENY, - message="Ruling: tabs", - ) == { + def test_a_question_answer_is_an_allow_carrying_the_answered_input(self) -> None: + """Measured on 2.1.273 (#371): the terminal shows the answer, not a denial.""" + answered = {"questions": [group("Tabs or spaces?", "spaces", "tabs")], "answers": {}} + + assert hook_decision(ApprovalVerdict.ALLOW, updated_input=answered) == { "hookSpecificOutput": { "hookEventName": HOOK_EVENT, - "decision": { - "behavior": DENY_BEHAVIOR, - "message": "Ruling: tabs", - }, + "decision": {"behavior": ALLOW_BEHAVIOR, "updatedInput": answered}, } } + def test_an_answered_input_never_rides_a_denial(self) -> None: + decision = hook_decision(ApprovalVerdict.DENY, updated_input={"answers": {}}) + + assert decision is not None + assert "updatedInput" not in decision["hookSpecificOutput"]["decision"] + def test_ask_prints_nothing_at_all(self) -> None: """The locked never-deny rule, as a wire fact. @@ -247,7 +278,7 @@ def test_no_verdict_ever_grants_beyond_the_one_call(self) -> None: 2.1.238 accepts `updatedPermissions` on an allow, which would write a session-scoped rule, and `updatedInput`, which would rewrite the call the - user said yes to. Neither may ever appear. + user said yes to. Neither may ever appear on a permission verdict. """ for verdict in ( ApprovalVerdict.ALLOW, @@ -963,11 +994,12 @@ def group(question: str, *labels: str, multi: bool = False) -> dict[str, Any]: class TestAQuestionRidesTheHeldHook: """`AskUserQuestion` is typed as a question on the shared hook route. - Measured on 2.1.246 (#77): the tool raises a `PermissionRequest`, and a hook - `deny` carrying a message is consumed by the Session *as the user's answer*. - #128 therefore parks it under Claude's `prompt_id` when present, or a - listener-private key otherwise, so the next Answer Relay can use the held - hook as its private transport and never the permission route. + Measured on 2.1.246 (#77): the tool raises a `PermissionRequest`. Measured + on 2.1.273 (#371): a hook `allow` whose `updatedInput` carries `answers` is + consumed by the Session *as the user's answer*, with no error styling. #128 + parks it under Claude's `prompt_id` when present, or a listener-private key + otherwise, so the next Answer Relay can use the held hook as its private + transport and never the permission route. """ def test_a_question_payload_projects_the_whole_question(self) -> None: @@ -1004,14 +1036,18 @@ async def scenario(): receipt, decision = asyncio.run(scenario()) + asked = group("Tabs or spaces?", "spaces", "tabs") assert (receipt.outcome, decision) == ( Delivery.DELIVERED, { "hookSpecificOutput": { "hookEventName": HOOK_EVENT, "decision": { - "behavior": DENY_BEHAVIOR, - "message": "Ruling: tabs", + "behavior": ALLOW_BEHAVIOR, + "updatedInput": { + "questions": [asked], + "answers": {"Tabs or spaces?": "tabs"}, + }, }, } }, @@ -1117,7 +1153,143 @@ async def scenario(): decision = asyncio.run(scenario()) assert decision is not None - assert decision["hookSpecificOutput"]["decision"]["message"] == "Ruling: use a hybrid " + assert decision["hookSpecificOutput"]["decision"]["updatedInput"]["answers"] == { + "Tabs or spaces?": " use a hybrid " + } + + def test_a_multi_question_call_hears_the_words_under_every_question( + self, socket_root: Path + ) -> None: + """One utterance, several questions: each is answered with those words. + + Canonicalised against each question's own labels, so a label spoken for + one question does not become a claimed choice under another. + """ + + async def scenario(): + listener = ApprovalListener( + settings=settings_for(socket_root), + resolve=lambda _: TARGET, + emit=Sink().emit, + pid=49, + ) + await listener.start() + try: + hook = await hook_in_flight( + listener, + socket_root, + question_dialog( + group("Tabs or spaces?", "spaces", "tabs"), + group("Which base?", "main", "develop"), + ), + ) + receipt = await listener.answer_question("p-1", "TABS", request_id=RequestId("r-m")) + return receipt, await hook + finally: + await listener.aclose() + + receipt, decision = asyncio.run(scenario()) + + assert receipt.outcome is Delivery.DELIVERED + assert decision is not None + assert decision["hookSpecificOutput"]["decision"]["updatedInput"]["answers"] == { + "Tabs or spaces?": "tabs", + "Which base?": "TABS", + } + + def test_an_offered_label_is_answered_with_the_label_as_written( + self, socket_root: Path + ) -> None: + """The spoken label has no mark; the answer names the label the Session wrote. + + Claude Code reads an answer that is one of the offered labels as a plain + choice (`Your questions have been answered`), and that test compares the + label exactly, `(recommended)` and all. + """ + + async def scenario(): + listener = ApprovalListener( + settings=settings_for(socket_root), + resolve=lambda _: TARGET, + emit=Sink().emit, + pid=50, + ) + await listener.start() + try: + hook = await hook_in_flight( + listener, + socket_root, + question_dialog(group("Which base?", "main (Recommended)", "develop")), + ) + await listener.answer_question("p-1", " Main ", request_id=RequestId("r-rec")) + return await hook + finally: + await listener.aclose() + + decision = asyncio.run(scenario()) + + assert decision is not None + assert decision["hookSpecificOutput"]["decision"]["updatedInput"]["answers"] == { + "Which base?": "main (Recommended)" + } + + def test_a_question_with_no_text_to_answer_under_is_refused_unwritten( + self, socket_root: Path + ) -> None: + """`answers` is keyed by the question's text, so none means no answer to give. + + Refused before anything is written, so the hook stays parked and the + dialog on screen keeps the question. + """ + + async def scenario(): + listener = ApprovalListener( + settings=settings_for(socket_root), + resolve=lambda _: TARGET, + emit=Sink().emit, + pid=51, + ) + await listener.start() + try: + await hook_in_flight( + listener, + socket_root, + question_dialog(group("", "spaces", "tabs")), + ) + receipt = await listener.answer_question("p-1", "tabs", request_id=RequestId("r-0")) + return receipt, listener.question_answerable(TARGET) + finally: + await listener.aclose() + + receipt, answerable = asyncio.run(scenario()) + + assert receipt.outcome is Delivery.FAILED + assert receipt.reason.strip() + assert answerable is True + + @pytest.mark.parametrize( + "frame", + [ + {"type": "approval_verdict", "verdict": "allow", "answer": 7}, + {"type": "approval_verdict", "verdict": "deny", "answer": "tabs"}, + ], + ids=["answer-not-text", "answer-on-a-denial"], + ) + def test_a_malformed_answer_frame_is_silence( + self, socket_root: Path, frame: dict[str, Any] + ) -> None: + assert ( + decide_against_one_frame( + socket_root, question_dialog(group("Tabs or spaces?", "spaces", "tabs")), frame + ) + is None + ) + + def test_an_answer_frame_for_a_permission_dialog_is_silence(self, socket_root: Path) -> None: + """An answer rewrites a question's input and nothing else's.""" + frame = {"type": "approval_verdict", "verdict": "allow", "answer": "yes"} + + assert decide_against_one_frame(socket_root, dialog(), frame) is None def test_the_session_s_own_mark_on_a_label_is_read_as_one(self) -> None: """`AskUserQuestion` has no recommendation field; the mark is in the label. @@ -1215,7 +1387,9 @@ async def scenario(): assert answerable is True assert receipt.outcome is Delivery.DELIVERED assert decision is not None - assert decision["hookSpecificOutput"]["decision"]["message"] == "Ruling: tabs" + assert decision["hookSpecificOutput"]["decision"]["updatedInput"]["answers"] == { + "Tabs or spaces?": "tabs" + } assert [event.window for event in windows] == [ReplyWindow.OPEN, ReplyWindow.CLOSED] def test_a_question_is_parked_without_entering_the_approval_relay( From 753e034dfbab1be6517ef17c56b39b320dea5d62 Mon Sep 17 00:00:00 2001 From: Simon Qi Date: Thu, 17 Sep 2026 11:39:27 +1200 Subject: [PATCH 2/5] Wrap Relays in the cross-session envelope with a folded source hint (#372) A Relay written into a Claude Session's inbox now wears the envelope Claude Code's own SendMessage uses, so the receiver's terminal folds it into one "Message from @GPT-VoiceCoding" line instead of a full block with the peer notice. Inside the envelope, after the words and a blank line, one sentence tells the model whose words these are and to confirm with AskUserQuestion when acting needs consent. Measured on Claude Code 2.1.273. - inbox.enveloped / unenveloped own the shape; RELAY_SENDER_NAME and SOURCE_HINT are single constants beside the reply-socket ones. - No from-mode, as before. - relay_payload strips the envelope and the hint, so History and Session Names still show only the user's words; a pre-#372 Relay still unwraps. - Correlation is unchanged (origin.from + msg_id), now pinned with the new origin fields. - A Session named like the label is not mistaken for a Relay: recognition stays on the reply socket's name. Closes #372 Co-Authored-By: Claude Opus 5 --- .../adapters/agent/claude/inbox.py | 83 ++++++++++++++++++- .../adapters/agent/claude/stop_analysis.py | 8 +- tests/test_claude_agent.py | 7 +- tests/test_claude_inbox.py | 65 ++++++++++++++- tests/test_claude_stop_analysis.py | 8 ++ tests/test_claude_transcript_tail.py | 30 +++++-- 6 files changed, 189 insertions(+), 12 deletions(-) diff --git a/src/gpt_voicecoding/adapters/agent/claude/inbox.py b/src/gpt_voicecoding/adapters/agent/claude/inbox.py index 57d53f0..9a2af04 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/inbox.py +++ b/src/gpt_voicecoding/adapters/agent/claude/inbox.py @@ -123,6 +123,39 @@ #: directory we are binding into, which is not ours. REPLY_SOCKET_PREFIX: Final = "vc-relay-" +#: The name a Relay wears in the receiver's terminal: `Message from @`. +#: Upstream calls the envelope's name *"Sender-asserted on the socket lane: a +#: label, not an identity proof"*, and it is used here as exactly that. Nothing +#: recognises a Relay by it — `stop_analysis.is_own_relay` reads the reply +#: socket's name — so a Session that happens to share it is not mistaken for us. +RELAY_SENDER_NAME: Final = "GPT-VoiceCoding" + +#: The tag Claude Code's own `SendMessage` wraps a peer message in. +ENVELOPE_TAG: Final = "cross-session-message" + +#: One sentence inside the envelope, after the words and a blank line (#372). +#: +#: The model is told, by a notice upstream appends to every peer message, that +#: this is not its user and cannot approve a pending prompt. That notice cannot +#: be removed, so this sentence is the whole remedy that reaches the model: +#: +#: - *Relayed from your user's voice or Telegram* — says whose words these are, +#: and by which route, so the model does not read them as another agent's. +#: - *by GPT-VoiceCoding* — names the carrier the terminal label already shows. +#: - *when acting on it needs their consent* — scopes the instruction to the +#: moment the model would otherwise refuse; ordinary work goes ahead. +#: - *confirm with AskUserQuestion* — the positive action. That dialog carries +#: the user's authority when answered through the held hook (ADR 0015), so the +#: refusal becomes a question the user can answer from the phone. +#: +#: Measured once on 2.1.273: a relayed delete plus this sentence made the model +#: ask with `AskUserQuestion`, and act on the answer. The terminal folds the +#: envelope to its first line, so the user sees their words and not this. +SOURCE_HINT: Final = ( + f"Relayed from your user's voice or Telegram by {RELAY_SENDER_NAME}; " + "when acting on it needs their consent, confirm with AskUserQuestion." +) + #: The `claude` this wrapper survey was measured against, on 2026-09-09. It is #: the version whose binary was read, not a floor and not a ceiling: the strings @@ -133,6 +166,16 @@ #: is repeatable, which is why the version it was taken at is recorded with it. WRAPPER_PROVEN_AGAINST_VERSION: Final = "2.1.266" +#: The `claude` the envelope was measured against, on 2026-09-17 (#372). The +#: receiver parses the envelope and rebuilds it, and uses it only if the rebuild +#: reproduces the text exactly: attributes in the order `from`, `from-session`, +#: `hop-chain`, `from-name`, `from-mode`, then a newline, the body, a newline and +#: the closing tag. The body is matched greedily up to the *last* closing tag, +#: so words that contain the tag still parse (read from the binary, not run). +#: An envelope it cannot parse is still delivered, and measured still folded in +#: the terminal, but its `origin` then carries no `name` and no `body`. +ENVELOPE_PROVEN_AGAINST_VERSION: Final = "2.1.273" + #: How the receiver wraps a peer message before writing it into the target's #: transcript: `
\n\n\n`. Read off the assembler in the #: 2.1.266 binary rather than inferred from samples, so the variants nobody has @@ -169,8 +212,44 @@ class InboxError(Exception): """The inbox could not be reached, or our own reply socket could not be bound.""" +def enveloped(words: str, *, sender: str) -> str: + """The words as a Relay carries them: in the envelope, with the source hint. + + The envelope is what makes the receiver's terminal fold the Relay into one + `Message from @` line, the way it shows its own `SendMessage`. It does + not change what the model is told about the sender; `SOURCE_HINT` is what + addresses that. + + No `from-mode`, for the same reason `user_frame` sends no `from_mode`. + """ + return ( + f'<{ENVELOPE_TAG} from="{sender}" from-name="{RELAY_SENDER_NAME}">\n' + f"{words}\n\n{SOURCE_HINT}\n" + f"" + ) + + +def unenveloped(text: str) -> str | None: + """The words inside an envelope, without the source hint, or `None`. + + `None` when `text` is not one whole envelope. A body without the hint (a + Relay sent before #372 added it) comes back whole. + """ + opening, newline, rest = text.partition("\n") + closing = f"\n" + if ( + not newline + or not (opening == f"<{ENVELOPE_TAG}>" or opening.startswith(f"<{ENVELOPE_TAG} ")) + or not opening.endswith(">") + or not rest.endswith(closing) + ): + return None + body = rest[: -len(closing)] + return body.removesuffix(f"\n\n{SOURCE_HINT}") + + def user_frame(text: str, *, msg_id: str, reply_to: str) -> dict[str, Any]: - """One Relay on the wire. + """One Relay on the wire, its words in `enveloped` form. `msg_id` is validated by the receiver against a UUID pattern, and an id of another shape is dropped from the `origin` record it writes — which would @@ -186,7 +265,7 @@ def user_frame(text: str, *, msg_id: str, reply_to: str) -> dict[str, Any]: return { "type": USER_TYPE, "msgV": MESSAGE_VERSION, - "message": {"role": "user", "content": text}, + "message": {"role": "user", "content": enveloped(text, sender=reply_to)}, "from": reply_to, "msg_id": msg_id, } diff --git a/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py b/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py index e6f22f0..299a945 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py +++ b/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py @@ -49,6 +49,7 @@ REPLY_SOCKET_PREFIX, WRAPPER_HEADERS, WRAPPER_TAIL_OPENINGS, + unenveloped, ) from gpt_voicecoding.seams.agent import Option, WaitingFor, WaitingKind @@ -617,6 +618,10 @@ def relay_payload(text: str) -> str: that changed — silently truncates or drops what the user said. Losing the user's words is the failure this ticket exists to end, so the doubt is spent on saying too much rather than too little. + + Inside the wrapper, a Relay since #372 is an envelope holding the words and + the source hint (`inbox.enveloped`); both are ours and both are stripped. A + payload that is not an envelope — a Relay sent before #372 — is the words. """ header, newline, remainder = text.partition("\n") if not newline or header not in WRAPPER_HEADERS: @@ -624,7 +629,8 @@ def relay_payload(text: str) -> str: payload, separator, tail = remainder.rpartition("\n\n") if not separator or not tail.startswith(WRAPPER_TAIL_OPENINGS): return text - return payload + words = unenveloped(payload) + return payload if words is None else words def is_pipeline_noise(record: Mapping[str, Any], content: Any) -> bool: diff --git a/tests/test_claude_agent.py b/tests/test_claude_agent.py index 0b8b174..b570a5f 100644 --- a/tests/test_claude_agent.py +++ b/tests/test_claude_agent.py @@ -372,7 +372,7 @@ async def scenario(): receipt, relays = asyncio.run(scenario()) assert receipt.outcome is Delivery.UNKNOWN assert "nothing proved the words arrived" in receipt.reason - assert [frame["message"]["content"] for frame in relays] == ["ship it"] + assert [inbox.unenveloped(frame["message"]["content"]) for frame in relays] == ["ship it"] def test_the_frame_carries_a_uuid_and_our_own_reply_address(self, socket_path: Path) -> None: """Both halves of the correlator, and the id's shape is load-bearing. @@ -649,7 +649,10 @@ async def scenario(): first, second, relays = asyncio.run(scenario()) assert first.outcome is Delivery.FAILED assert second.outcome is Delivery.FAILED - assert sorted(frame["message"]["content"] for frame in relays) == ["first", "second"] + assert sorted(inbox.unenveloped(frame["message"]["content"]) or "" for frame in relays) == [ + "first", + "second", + ] assert (first.request_id, second.request_id) == ("r-1", "r-2") assert len({frame["msg_id"] for frame in relays}) == 2, "two Relays, two correlators" diff --git a/tests/test_claude_inbox.py b/tests/test_claude_inbox.py index 18348c2..06b1192 100644 --- a/tests/test_claude_inbox.py +++ b/tests/test_claude_inbox.py @@ -27,8 +27,10 @@ InboxError, ReplyInbox, correlated, + enveloped, own_process_start, published_start, + unenveloped, user_frame, ) from gpt_voicecoding.adapters.agent.claude.privacy import PRIVATE_SOCKET_MODE @@ -48,9 +50,60 @@ def test_the_words_ride_where_a_peer_message_carries_them(self) -> None: frame = user_frame("ship it", msg_id="m-1", reply_to="uds:/tmp/x.sock") assert frame["type"] == "user" - assert frame["message"] == {"role": "user", "content": "ship it"} + assert frame["message"] == { + "role": "user", + "content": enveloped("ship it", sender="uds:/tmp/x.sock"), + } assert frame["msgV"] == inbox.MESSAGE_VERSION + def test_the_envelope_is_the_one_the_receiver_folds(self) -> None: + """Byte for byte what folded to one line on 2.1.273 (#372). + + The receiver rebuilds the envelope and uses it only on an exact match, + so attribute order, the newlines, and the absence of `from-mode` all + decide whether the terminal folds it. + """ + assert enveloped("直接删掉 /tmp/x.txt", sender="uds:/tmp/cc-socks/vc-relay-1.sock") == ( + '\n' + "直接删掉 /tmp/x.txt\n\n" + "Relayed from your user's voice or Telegram by GPT-VoiceCoding; when acting on it" + " needs their consent, confirm with AskUserQuestion.\n" + "" + ) + + @pytest.mark.parametrize( + "words", + [ + "ship it", + 'say "yes" ', + "line one\n\nline two\n", + "an early tag, then more", + "", + ], + ids=["plain", "quotes-and-brackets", "newlines", "closing-tag", "empty"], + ) + def test_the_words_come_back_out_intact(self, words: str) -> None: + assert unenveloped(enveloped(words, sender="uds:/tmp/x.sock")) == words + + def test_a_body_without_the_hint_comes_back_whole(self) -> None: + assert unenveloped( + '\nhi\n' + ) == ("hi") + + @pytest.mark.parametrize( + "text", + [ + "ship it", + "\nno closing tag", + "\nhi\n", + "hi", + ], + ids=["bare", "unclosed", "other-tag", "one-line"], + ) + def test_anything_but_one_whole_envelope_is_not_unwrapped(self, text: str) -> None: + assert unenveloped(text) is None + def test_nothing_claims_a_priority_or_attests_a_permission_mode(self) -> None: """Two fields the wire accepts and this product may not send. @@ -77,6 +130,16 @@ def test_our_own_message_arriving_is_the_proof(self) -> None: assert correlated(records, msg_id="m-1", address=self.ADDRESS) + def test_the_envelope_s_extra_origin_fields_change_nothing(self) -> None: + """2.1.273 adds `name` and `body` for an enveloped Relay and keeps both halves.""" + records = ( + self.record( + **{"from": self.ADDRESS, "msg_id": "m-1", "name": "GPT-VoiceCoding", "body": "hi"} + ), + ) + + assert correlated(records, msg_id="m-1", address=self.ADDRESS) + def test_the_same_id_from_another_sender_is_not_ours(self) -> None: records = (self.record(**{"from": "uds:/tmp/cc-socks/someone.sock", "msg_id": "m-1"}),) diff --git a/tests/test_claude_stop_analysis.py b/tests/test_claude_stop_analysis.py index f50958a..11891f9 100644 --- a/tests/test_claude_stop_analysis.py +++ b/tests/test_claude_stop_analysis.py @@ -18,6 +18,7 @@ import pytest +from gpt_voicecoding.adapters.agent.claude.inbox import RELAY_SENDER_NAME from gpt_voicecoding.adapters.agent.claude.stop_analysis import ( QUESTION_TOOL, SUMMARY_MAX_CHARS, @@ -757,6 +758,13 @@ def test_our_own_answer_relay_does_not_leave_it(self) -> None: assert found.kind is WaitingKind.PEER assert found.awaiting == PEER_NAME + def test_a_session_named_like_our_relays_is_still_told_apart_from_them(self) -> None: + """#372: a Relay wears `RELAY_SENDER_NAME`; only the reply socket says it is ours.""" + waiting = [sent("t1", RELAY_SENDER_NAME), delivered("t1")] + + assert analyse([*waiting, relayed("any news?")]).kind is WaitingKind.PEER + assert analyse([*waiting, peer_wrote(name=RELAY_SENDER_NAME)]).kind is WaitingKind.NONE + def test_a_question_the_user_must_answer_outranks_a_peer_send(self) -> None: """Only the user can end a question; the peer send is behind it.""" found = analyse( diff --git a/tests/test_claude_transcript_tail.py b/tests/test_claude_transcript_tail.py index aaf9208..2034642 100644 --- a/tests/test_claude_transcript_tail.py +++ b/tests/test_claude_transcript_tail.py @@ -18,6 +18,7 @@ from typing import Any from fakes import PROGRESS_CAPTURE, capture_for +from gpt_voicecoding.adapters.agent.claude.inbox import RELAY_SENDER_NAME, SOURCE_HINT, enveloped from gpt_voicecoding.adapters.agent.claude.stop_analysis import QUESTION_TOOL from gpt_voicecoding.adapters.agent.claude.transcript_tail import recent from gpt_voicecoding.seams.agent import ProgressOmission, ProgressRole @@ -219,13 +220,25 @@ def relayed( at: int = 0, origin_from: str = "uds:/tmp/cc-socks/vc-relay-60460.sock", text: str | None = None, + wrapped: bool = True, ) -> dict[str, Any]: """One Answer Relay of ours, in the shape Claude Code really recorded it. Every field is copied from a real delivery — run `20260903T233723Z`, `二号工位`, `~/.claude/projects/…/1a6d8d85-….jsonl` record 61, the one the - ticket's symptom was read from. + ticket's symptom was read from. `wrapped` adds what #372's envelope added on + 2.1.273 (brainstorming session `ae52b929-….jsonl` record 175): the payload + is `inbox.enveloped`, and `origin` gains `name` and `body`. """ + origin: dict[str, Any] = { + "kind": "peer", + "from": origin_from, + "msg_id": "6877a163-f973-4342-8011-54c75b543f1d", + } + if wrapped: + origin["name"] = RELAY_SENDER_NAME + origin["body"] = f"{payload}\n\n{SOURCE_HINT}" + payload = enveloped(payload, sender=origin_from) return { "type": "user", "isSidechain": False, @@ -233,11 +246,7 @@ def relayed( "isMeta": True, "promptSource": "system", "timestamp": stamp(at), - "origin": { - "kind": "peer", - "from": origin_from, - "msg_id": "6877a163-f973-4342-8011-54c75b543f1d", - }, + "origin": origin, "message": { "role": "user", "content": text @@ -266,6 +275,15 @@ def test_the_relayed_words_are_a_user_entry_between_their_neighbours(self) -> No (2, ProgressRole.ASSISTANT, "那我就接着往下做。"), ] + def test_a_relay_sent_before_the_envelope_is_still_the_words(self) -> None: + """History reaches back past #372, to Relays that carried bare words.""" + assert texts([relayed("可以继续", wrapped=False)]) == ["可以继续"] + + def test_neither_the_envelope_nor_the_source_hint_reaches_history(self) -> None: + words = '第一行\n\n第二行 "引号" 结尾' + + assert texts([relayed(words)]) == [words] + def test_an_earlier_engine_process_relay_is_still_ours(self) -> None: """The address carries a pid and a configurable directory; the shape is ours.""" entries, _, _ = recent( From 8121c645b45ab4ef46987d90e85bce2ec75f219d Mon Sep 17 00:00:00 2001 From: Simon Qi Date: Thu, 17 Sep 2026 11:45:18 +1200 Subject: [PATCH 3/5] Name AskUserQuestion's fields once, and the answerability check - stop_analysis owns the tool's input field names; the approval hook reads them from there instead of keeping a second copy. - approval.answerable replaces `answered_input(payload, "") is not None`. Review follow-up for #371. Co-Authored-By: Claude Opus 5 --- .../adapters/agent/claude/approval.py | 30 +++++++++++-------- .../adapters/agent/claude/stop_analysis.py | 19 ++++++++---- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/gpt_voicecoding/adapters/agent/claude/approval.py b/src/gpt_voicecoding/adapters/agent/claude/approval.py index edeb5c8..3eed2f1 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/approval.py +++ b/src/gpt_voicecoding/adapters/agent/claude/approval.py @@ -178,26 +178,37 @@ def answered_input(payload: Mapping[str, Any], words: str) -> dict[str, Any] | N if payload.get(TOOL_NAME_FIELD) != stop_analysis.QUESTION_TOOL: return None tool_input = payload.get(TOOL_INPUT_FIELD) - questions = tool_input.get(QUESTIONS_FIELD) if isinstance(tool_input, Mapping) else None + if not isinstance(tool_input, Mapping): + return None + questions = tool_input.get(stop_analysis.QUESTIONS_FIELD) if not isinstance(questions, list): return None answers: dict[str, str] = {} for question in questions: if not isinstance(question, Mapping): continue - text = question.get(QUESTION_FIELD) + text = question.get(stop_analysis.QUESTION_FIELD) if isinstance(text, str) and text.strip(): - answers[text] = _as_offered(question.get(OPTIONS_FIELD), words) + answers[text] = _as_offered(question.get(stop_analysis.OPTIONS_FIELD), words) if not answers: return None - return {**tool_input, ANSWERS_FIELD: answers} + return {**tool_input, stop_analysis.ANSWERS_FIELD: answers} + + +def answerable(payload: Mapping[str, Any]) -> bool: + """Whether `answered_input` can answer this dialog at all. + + The same reading of the same payload the hook will make, so the engine + refuses exactly the answers the hook could not deliver. + """ + return answered_input(payload, "") is not None def _as_offered(options: Any, words: str) -> str: """The offered label these words name, as written, or the words unchanged.""" spoken = _comparable(words) for option in options if isinstance(options, list) else (): - label = option.get(LABEL_FIELD) if isinstance(option, Mapping) else None + label = option.get(stop_analysis.LABEL_FIELD) if isinstance(option, Mapping) else None if not isinstance(label, str) or not label.strip(): continue unmarked, _ = stop_analysis.split_recommendation(label.strip()) @@ -262,13 +273,6 @@ def _comparable(text: str) -> str: #: as spoken; the hook holds the call's input and builds `answers` from it. ANSWER_FIELD: Final = "answer" -#: `AskUserQuestion`'s own input fields, as its tool schema names them. -QUESTIONS_FIELD: Final = "questions" -QUESTION_FIELD: Final = "question" -OPTIONS_FIELD: Final = "options" -LABEL_FIELD: Final = "label" -ANSWERS_FIELD: Final = "answers" - #: The registration's own fields. `transcript_path` is the one that earns this #: hook its place (#71): Claude Code's own registry does not carry it, and it #: cannot be derived without guessing at the directory-name flattening — which @@ -792,7 +796,7 @@ async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWrite writer, permission=request, question=question, - keyed=answered_input(payload, "") is not None, + keyed=answerable(payload), ) self._waiting[approval_id] = waiting if question is not None: diff --git a/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py b/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py index 299a945..5c6640f 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py +++ b/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py @@ -64,6 +64,15 @@ #: this marker has no recommendation to report. RECOMMENDED_MARKER: Final = "(recommended)" +#: `AskUserQuestion`'s own input fields, as its tool schema names them. Read +#: here to announce a question, and by the approval hook to answer one. +QUESTIONS_FIELD: Final = "questions" +QUESTION_FIELD: Final = "question" +OPTIONS_FIELD: Final = "options" +LABEL_FIELD: Final = "label" +DESCRIPTION_FIELD: Final = "description" +ANSWERS_FIELD: Final = "answers" + #: The input fields a Claude permission request may be summarised from, in the #: order they are preferred. Each is a short human-facing string the product #: already writes for a person to read. The arguments proper — `command`, @@ -469,24 +478,24 @@ def _groups(tool_input: Any) -> list[_QuestionGroup]: """ if not isinstance(tool_input, Mapping): return [] - questions = tool_input.get("questions") + questions = tool_input.get(QUESTIONS_FIELD) if not isinstance(questions, list): return [] groups: list[_QuestionGroup] = [] for question in questions: if not isinstance(question, Mapping): continue - prompt = question.get("question") + prompt = question.get(QUESTION_FIELD) options: list[Option] = [] - raw = question.get("options") + raw = question.get(OPTIONS_FIELD) if isinstance(raw, list): for option in raw: if not isinstance(option, Mapping): continue - label = option.get("label") + label = option.get(LABEL_FIELD) if isinstance(label, str) and label.strip(): text, is_recommended = split_recommendation(label.strip()) - raw_description = option.get("description") + raw_description = option.get(DESCRIPTION_FIELD) description = ( raw_description.strip() if isinstance(raw_description, str) and raw_description.strip() From 320078b79662518d2bf7d254cbd0584d5526472f Mon Sep 17 00:00:00 2001 From: Simon Qi Date: Thu, 17 Sep 2026 11:50:51 +1200 Subject: [PATCH 4/5] Answer a multi-question call with the words as its response (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spoken or typed reply is one string with nothing saying which part answers which question. Putting it under every question credited each question with words meant for another. A call with several questions now gets the words as `updatedInput.response` and no `answers`; the Session reads "The user responded: " and decides. Measured live on Claude Code 2.1.273 (brainstorming-97): a reply of "第一个用 tabs,第二个用 main" to a two-question call was recorded as `response`, the hook decision as `allow`, and the model applied tabs and main to the right questions. A lone question keeps `answers` with offered-label canonicalisation. Co-Authored-By: Claude Opus 5 --- ...-a-held-question-opens-the-reply-window.md | 14 ++-- .../adapters/agent/claude/approval.py | 82 +++++++++++-------- .../adapters/agent/claude/stop_analysis.py | 1 + tests/test_claude_approval.py | 32 +++++--- 4 files changed, 78 insertions(+), 51 deletions(-) diff --git a/docs/adr/0015-a-held-question-opens-the-reply-window.md b/docs/adr/0015-a-held-question-opens-the-reply-window.md index 0a90e68..b12b5f6 100644 --- a/docs/adr/0015-a-held-question-opens-the-reply-window.md +++ b/docs/adr/0015-a-held-question-opens-the-reply-window.md @@ -109,14 +109,16 @@ as `answer` beside `verdict: "allow"`; the hook, which already holds the call's stdin, builds `answers` (`approval.answered_input`). `Ruling: ` and the frame it named are retired: there is no error wrapper left for a prefix to be read under. -- **Canonicalisation moves to the hook** and stays inside the adapter. Per question, words that - match one of that question's labels — ignoring case, spacing and the `(recommended)` mark — +- **Canonicalisation moves to the hook** and stays inside the adapter. For a lone question, words + that match one of its labels — ignoring case, spacing and the `(recommended)` mark — become the label exactly as the Session wrote it, which is the comparison Claude Code makes before calling the answers a plain choice. Anything else is sent verbatim. -- **One utterance answers every question in a call.** The words carry no per-question split, so - each question with text receives them whole, canonicalised against its own labels. A call with no - question text has no key to put the words under; the engine refuses that Relay before writing, and - the dialog on screen keeps the question. +- **A call with several questions gets the words as its `response`**, and no `answers`. The words + carry no per-question split, so the Session reads them whole — `The user responded: ` — + and decides which part answers which question. Measured on 2.1.273: a reply of "第一个用 tabs,第二个用 + main" to a two-question call was split correctly by the model. No question is credited with words + meant for another. A lone question with no text has no key to put the words under; the engine + refuses that Relay before writing, and the dialog on screen keeps the question. - **Permission verdicts are unchanged**: plain `allow`, or `deny` with `DENIED_BY_VOICE`, and never `updatedInput` or `updatedPermissions`. The hook ignores an `answer` on any frame but an `allow` for an `AskUserQuestion`, and prints nothing for it. diff --git a/src/gpt_voicecoding/adapters/agent/claude/approval.py b/src/gpt_voicecoding/adapters/agent/claude/approval.py index 3eed2f1..edaae2b 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/approval.py +++ b/src/gpt_voicecoding/adapters/agent/claude/approval.py @@ -161,38 +161,36 @@ def hook_decision( def answered_input(payload: Mapping[str, Any], words: str) -> dict[str, Any] | None: - """One `AskUserQuestion` call's input with the user's words as its `answers`. - - `None` for anything that is not a question with at least one question text - to answer under, because Claude Code keys `answers` by that exact text. - - **One utterance answers every question in the call.** A spoken or typed reply - arrives as one string with no per-question split, so each question gets it - whole and the model reads it against each. Per question, words that match - one of *that* question's labels (ignoring case, spacing and the - `(recommended)` mark) become the label exactly as written. Claude Code - treats an all-label answer as a plain choice (`Your questions have been - answered`); anything else is delivered verbatim and read as free text - (`The user answered`). + """One `AskUserQuestion` call's input with the user's words as its answer. + + `None` for anything this cannot answer: not a question, or a lone question + with no text, since Claude Code keys `answers` by that exact text. + + **One question: the words are its `answers` entry.** Words that match one of + its labels (ignoring case, spacing and the `(recommended)` mark) become the + label exactly as written; Claude Code treats that as a plain choice (`Your + questions have been answered`). Anything else is sent verbatim and read as + free text (`The user answered`). + + **Several questions: the words are the call's `response`.** A spoken or + typed reply is one string with nothing saying which part answers which + question, so the Session gets it whole (`The user responded: `) and + decides. Measured on 2.1.273: it split "tabs for the first, main for the + second" across its two questions correctly. `answers` is left out, so no + question is credited with words meant for another. """ - if payload.get(TOOL_NAME_FIELD) != stop_analysis.QUESTION_TOOL: - return None - tool_input = payload.get(TOOL_INPUT_FIELD) - if not isinstance(tool_input, Mapping): - return None - questions = tool_input.get(stop_analysis.QUESTIONS_FIELD) - if not isinstance(questions, list): + asked = _asked(payload) + if asked is None: return None - answers: dict[str, str] = {} - for question in questions: - if not isinstance(question, Mapping): - continue - text = question.get(stop_analysis.QUESTION_FIELD) - if isinstance(text, str) and text.strip(): - answers[text] = _as_offered(question.get(stop_analysis.OPTIONS_FIELD), words) - if not answers: + tool_input, questions = asked + if len(questions) > 1: + return {**tool_input, stop_analysis.RESPONSE_FIELD: words} + (question,) = questions + text = question.get(stop_analysis.QUESTION_FIELD) + if not isinstance(text, str) or not text.strip(): return None - return {**tool_input, stop_analysis.ANSWERS_FIELD: answers} + answer = _as_offered(question.get(stop_analysis.OPTIONS_FIELD), words) + return {**tool_input, stop_analysis.ANSWERS_FIELD: {text: answer}} def answerable(payload: Mapping[str, Any]) -> bool: @@ -204,6 +202,22 @@ def answerable(payload: Mapping[str, Any]) -> bool: return answered_input(payload, "") is not None +def _asked( + payload: Mapping[str, Any], +) -> tuple[Mapping[str, Any], list[Mapping[str, Any]]] | None: + """An `AskUserQuestion` call's input and its readable questions, or `None`.""" + if payload.get(TOOL_NAME_FIELD) != stop_analysis.QUESTION_TOOL: + return None + tool_input = payload.get(TOOL_INPUT_FIELD) + if not isinstance(tool_input, Mapping): + return None + listed = tool_input.get(stop_analysis.QUESTIONS_FIELD) + questions = [q for q in listed if isinstance(q, Mapping)] if isinstance(listed, list) else [] + if not questions: + return None + return tool_input, questions + + def _as_offered(options: Any, words: str) -> str: """The offered label these words name, as written, or the words unchanged.""" spoken = _comparable(words) @@ -426,8 +440,8 @@ def __init__( #: permission. Parsed once, here, when the payload arrives: two parses of #: one message are two answers that can disagree. self.question = question - #: Whether the question carries a text `answers` can be keyed by, read - #: off the same payload by the same builder the hook will use. + #: Whether the hook can answer this question at all, read off the same + #: payload by the same builder the hook will use. self.keyed = keyed #: Set once a verdict has been written to this hook. It is what tells the #: connection's own task that the end it is about to see is an ordinary @@ -640,9 +654,9 @@ async def answer_question( The words travel as spoken; the hook matches them to the offered labels (`answered_input`), because it holds the call's input and this does not. - A question with no text is refused before anything is written, since - `answers` has no key to put the words under, and the dialog on screen - keeps it. + A lone question with no text is refused before anything is written, + since `answers` has no key to put the words under, and the dialog on + screen keeps it. """ waiting = self._waiting.get(question_id) if waiting is None or waiting.question is None: diff --git a/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py b/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py index 5c6640f..819435d 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py +++ b/src/gpt_voicecoding/adapters/agent/claude/stop_analysis.py @@ -72,6 +72,7 @@ LABEL_FIELD: Final = "label" DESCRIPTION_FIELD: Final = "description" ANSWERS_FIELD: Final = "answers" +RESPONSE_FIELD: Final = "response" #: The input fields a Claude permission request may be summarised from, in the #: order they are preferred. Each is a short human-facing string the product diff --git a/tests/test_claude_approval.py b/tests/test_claude_approval.py index cd871cb..e6c3953 100644 --- a/tests/test_claude_approval.py +++ b/tests/test_claude_approval.py @@ -1157,13 +1157,18 @@ async def scenario(): "Tabs or spaces?": " use a hybrid " } - def test_a_multi_question_call_hears_the_words_under_every_question( - self, socket_root: Path + @pytest.mark.parametrize( + "second", ["Which base?", ""], ids=["both-have-text", "one-has-no-text"] + ) + def test_a_multi_question_call_gets_the_words_as_one_response( + self, socket_root: Path, second: str ) -> None: - """One utterance, several questions: each is answered with those words. + """One reply, several questions: the Session reads it whole and decides. - Canonicalised against each question's own labels, so a label spoken for - one question does not become a claimed choice under another. + Measured on 2.1.273: `response` reaches the model as `The user + responded: `, and it split "tabs for the first, main for the + second" across its two questions correctly. `answers` stays empty, so no + question is credited with words meant for another. """ async def scenario(): @@ -1180,10 +1185,12 @@ async def scenario(): socket_root, question_dialog( group("Tabs or spaces?", "spaces", "tabs"), - group("Which base?", "main", "develop"), + group(second, "main", "develop"), ), ) - receipt = await listener.answer_question("p-1", "TABS", request_id=RequestId("r-m")) + receipt = await listener.answer_question( + "p-1", "tabs for the first, main for the second", request_id=RequestId("r-m") + ) return receipt, await hook finally: await listener.aclose() @@ -1192,10 +1199,13 @@ async def scenario(): assert receipt.outcome is Delivery.DELIVERED assert decision is not None - assert decision["hookSpecificOutput"]["decision"]["updatedInput"]["answers"] == { - "Tabs or spaces?": "tabs", - "Which base?": "TABS", - } + updated = decision["hookSpecificOutput"]["decision"]["updatedInput"] + assert updated["response"] == "tabs for the first, main for the second" + assert "answers" not in updated + assert [question["question"] for question in updated["questions"]] == [ + "Tabs or spaces?", + second, + ] def test_an_offered_label_is_answered_with_the_label_as_written( self, socket_root: Path From 6111e72419a2fc0df174a4e5f3df1b7abba865a7 Mon Sep 17 00:00:00 2001 From: Simon Qi Date: Thu, 17 Sep 2026 11:52:36 +1200 Subject: [PATCH 5/5] Release 1.0.1 Co-Authored-By: Claude Opus 5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f7e4326..2c104dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "gpt-voicecoding" -version = "1.0.0" +version = "1.0.1" description = "Voice-controlling terminal coding agents through a realtime voice call." readme = "README.md" license = { file = "LICENSE" }