Skip to content

fix(voice): the relay agent's task table decides what is submitted, when results are said, and what the user heard - #5348

Merged
liususan091219 merged 30 commits into
mainfrom
fix/relay-spoken-and-paced
Oct 13, 2026
Merged

liususan091219 merged 30 commits into
mainfrom
fix/relay-spoken-and-paced

Conversation

@liususan091219

@liususan091219 liususan091219 commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Why

The relay agent (#5310) handed every result to the model the moment it was ready and counted the hand-off as heard. Live on the test Mac (2026-10-10):

Two days of live testing (2026-10-10 → 10-12, test Mac, this PR + EchoGuard #5347 + the bodhi replay fix) then found the rest of what the table has to know, below. Relay-agent state and mechanism only: no prompt rule is added anywhere in this PR.

What the relay agent now does

Hand-off pacing (createHandoffPacer, src/relay-agent.ts; every hand-off in the runtime goes through it):

  • One result at a time; the next waits until the model has answered the current one.
  • Confirmed by the model's turn: a turn in which the model's audio began records the result spoken. Said once is said: a cut-off turn no longer hands it over again (4e809ae; a 213 ms sound after a finished sentence counted as a cut-off and the answer was read twice, 2026-10-12 01:06). A turn that was only a tool call confirms nothing.
  • The user's turn first: no hand-off while the user speaks, nor until a spoken model turn has answered them; 8 s after the user's last turn with no reply, it goes anyway (noise ends turns nobody answers). Every user turn that ends re-checks (901814b).
  • Only the client microphone says when the user speaks (61a62cb): bodhi publishes a provider speech.user_started on every Gemini transcription chunk and never an end, so every utterance left the pacer (and the submit gate) believing the user was still talking; results waited up to the 60 s guard after each one.
  • A result whose client left while it waited is not handed to the empty session (fe7ce86): it takes the offline way (spoken on a reconnect within a minute, else one DM copy, read by reconcile on the next connection).
  • Never stuck: 60 s with no turn after a hand-off releases the next.
  • Reconcile leaves waiting results alone; a stale "not picked up" notice is dropped once the task is answered (4d9ea39).
  • Each result is said with which request it answers (fa7ec24): several arriving minutes late were otherwise bare answers the user could not match.

What the table knows (voiceTaskStore, in the voice agent's memory, like the pending-task map; capped at the newest 200 rows):

  • A task answered in another task's result ([deduped: <id>]) is heard with it, in either order of arrival (4d9ea39). Live 2026-10-11 22:41: 5229 and 5167 answered in 5302's result, said once, never handed over again (the 10-11 11:13 "5229 three times" case).
  • A corrected request (replaces) and a summary that already spoke for a task (fdcb44e).
  • A task the core ends with a skip-marked result is retired: nothing to hear, not pending (6769e39).
  • "How many pending" counts what the user has not heard, split into "the core is still working on N" and "M finished, waiting to be read" (ae17e62). get_task_status logs what it returned.
  • The table is in memory, the page reads a copy (04ef8cf, a982663): the voice agent owns the table, like bodhi's ConversationContext; state/voice-tasks.json is gone. The live page stays (/voice-tasks, /voice-tasks/view on the web client): it reads state/voice-task-status.json, which is written the way bodhi's history stores write the conversation: the table calls onChange after each change, and a writer copies it asynchronously, one write at a time, coalescing changes during a write. No polling timer; a failed write is logged and never slows the table.

Before a request is submitted:

  • A work call waits until the user has finished speaking (6965fdf, 1ae80ad): the provider ends the user's turn mid-sentence and the model calls work with half a request; a second call while the first is held means the user went on, and the two are one task. The model's reply ends the user's turn, so a request made after "Working on it" is a new task.
  • A text model checks the request (3deb7af, 797662b): gemini-3-flash, thinking off, one question over the request and the recent words — does it identify which thing it is about? If not, nothing is submitted and the model is told the one question to ask ("你说的是哪个 PR?"). Four prompt revisions had not made the voice model stop and ask; this layer is outside it. Any failure or 4 s lets the request through.

Tests

tests/relay-agent.test.ts 61, tests/relay-agent-e2e.test.ts 13, tests/voice-task-cancel.test.ts 11; tsc clean. Each fix carries a test written from its incident's log, checked to fail without the fix (see the commit messages for the mutation).

Live (test Mac)

  • 10-11 22:41 UTC: three PR checks behind a long task, answered in one result; said once, both other rows answeredBy, no replay.
  • 10-12 02:54 UTC: four requests → four tasks, four results each read once, "pending" 1 = page 1.
  • 10-12 03:17 → 04:01 UTC: "帮我查一下那个 PR 的状态" (no number) went from a blank task to a question back to the user; the named follow-up passed the check in 2.3 s and was submitted.
  • Not yet seen live: the "not picked up" notice colliding with a combined result (covered by the e2e test only).

Closes #5328; covers #5288. #5327 (a result cut off mid-answer is never said again) is closed as won't fix by decision: a result the model began is counted as said once (the said-once rule, 4e809ae), so a false cut-off does not repeat a whole answer.

🤖 Generated with Claude Code

liususan091219 and others added 4 commits October 10, 2026 17:57
…only once the model said them

Live on the test Mac: the results for PR 5275 and PR 5251 came back in the
same millisecond. Both were handed to the model at once (each through its
work call), the model answered only 5251, and 5275 was recorded delivered
(injected) and never said. The relay counted a hand-off as heard, and nothing
handed a cut-off answer over again (#5327, #5328).

createHandoffPacer (relay-agent.ts), used by every hand-off in the runtime
(a waiting work call, a system notice, a reconcile replay, a meeting's held
results):
- one result at a time: the next waits until the model has answered the
  current one;
- the model's next turn confirms it: a finished turn records it spoken; a
  cut-off turn hands it over once more (with a note), and a second cut-off
  leaves it;
- a hand-off no turn follows within 60 s releases the next one;
- the same task is never queued twice (reconcile replays every 5 s).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Live: while 5251 and 5035 waited behind 5275 in the pacer, reconcile saw
results with no delivery recorded and logged "result never heard; handing it
over again" every 5 s. The pacer dropped the duplicates, but each pass also
counted a replay against the task (cap 2), so a result that waited long enough
could use up its replays before it was ever handed over.

Reconcile's in-flight check (always false until now) is the pacer: a task with
a result queued or on its way is not replayed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… model has answered them

Live: the user said "I've had a cold for two weeks" and the result for PR
5275 came back in the same moment; the pacer saw an idle model and handed it
over, with bodhi's "Please inform the user now", and the model read the PR
status without answering the user. The relay knew the model's turns but not
the user's.

- The pacer follows the user's turns (bodhi speech.user_started /
  speech.user_ended): no hand-off while the user speaks, nor after until a
  finished model turn has answered them. If no reply follows within 8 s
  (noise can end a user turn Gemini never answers), the result goes anyway.
- Every result now ends with a line telling the model to answer anything the
  user said that it has not answered, briefly, first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… result — no prompt notes

Live: the user asked "how do I unblock it?"; the model only submitted a work
task (a silent turn), the pacer counted that turn as the model having answered
the user and handed over the next result, and the model read PR 5251's status
instead of answering. The same happened with the next question and PR 5035.

The pacer now counts a model turn only if its audio began (bodhi
response.first_audio): a silent, tool-call-only turn neither answers the user
(results keep waiting) nor confirms a handed-over result as spoken.

Removed the prompt notes added to results (the "answer the user first" line,
and the note on a re-handed result): timing is the relay's state, not prompt
text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

liususan091219 and others added 5 commits October 10, 2026 22:40
Live: a result came back while the user kept talking and every model reply
was cut off. The pacer re-checked only when a spoken model turn finished, not
when the user stopped speaking, so the 8 s wait never re-armed; reconcile saw
the result as in flight and left it. It was still waiting 3 min later.

- Every user turn that ends re-checks and starts the wait over: once the user
  has been quiet for 8 s with no answer, the result goes.
- A speech start whose end never arrives holds results only up to the 60 s
  hand-off wait.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…at already spoke for a task

Live: (1) the user corrected "51167" to "5167"; the old task's result came back
later and was said again. (2) The core wrote a readable summary of three PR
results besides each task's own result; the summary was said, then PR 5302's
own result again. The table had no row-to-row relation for either.

- A corrected request: `work` takes an optional `replaces` (the words that
  identify the earlier request, filled by the model when the user corrects
  one). The relay marks matching recent unheard rows `supersededBy` the new
  task: the old waiting call ends, its result is not said, reconcile leaves it.
- A summary with no task of its own: the table matches each open request's
  sure marks (numbers of 3+ digits, quoted names; 5167 is not 51167) against
  it. Once the summary is said, the tasks it named are `spoken`, and their own
  results are not said. A request with no sure mark is never matched, so the
  worst case is the old behaviour (said twice), never a dropped result.
- Every hand-off asks the table first (`resultIsMoot`) when its turn comes.

No prompt or CLAUDE.md change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… parameter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tale "not picked up" notice is dropped

Live test on 2026-10-11 (11:05-11:14 PT): three PR status requests, the core
answered all three in 5302's result and marked 5229 and 5167 deduped into it.
5229 was then said three times:

- 5302's result landed before the dedup markers, so the list of tasks it
  answers was fixed empty at arrival. Said through 5302's work call, it marked
  only 5302 heard; reconcile then saw 5229 and 5167 as never heard and handed
  5302's result over again for each.
- "Not picked up after 3m" notices queued while the model was silent carried
  no task, so nothing could tell they had gone stale; they were said after the
  results ("PR 5229 is still queued").

Now:
- The tasks a result answers are read when it is handed over, not when it lands.
- The table marks every task answered in a result with that result's delivery,
  whichever order the dedup link and the delivery arrive in; a task linked to a
  result already handed over ends its waiting call.
- A "not picked up" notice names its task and is dropped when its turn comes if
  the task is no longer queued or anything about it has reached the user.

Tests replay the incident: the combined result waiting its turn while the
markers land; the markers landing after it was said; a notice queued while the
user talks, then the result. All three fail on the parent commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live test on 2026-10-11 (22:18 UTC): after a stretch of forced reconnects,
three results were said back to back (a PR comment, two dish questions),
each as a bare answer. The model was told which request each answered, but
only for itself, so the user could not tell which answer went with which
question. The framing now asks the model to name the request, in a few
words in the user's language, before its answer (and before each one when a
result answers several).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live test on 2026-10-11 (23:31:55 UTC): the user paused mid-sentence, the
model ended their turn at "... repository 最近一" and called work at once;
the user was still talking (client VAD restarted 150 ms later) and said the
whole request again, so two tasks were queued and the second was "right
after the one I'm on". The model did not mark the second as a correction.

The relay agent now holds each work call until the user has been quiet for
1.5 s (client and provider speech events; never longer than 30 s). A call
made while an earlier one is still held means the user went on speaking:
the two are one request. The earlier call ends with "say nothing", and the
later one is submitted once, carrying the cut-off words so the core can do
both if they differ. Requests with a real pause between them stay separate.

Tests: four new relay-agent tests (the 23:31 sequence, the quiet wait, two
separate requests, a speech start with no end); three fail without the hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
liususan091219 and others added 9 commits October 11, 2026 17:00
…e new tasks

Live test on 2026-10-11 (23:42 UTC): four requests in a row, each answered
with "Working on it". Between them the microphone picked up sounds too short
to be speech, which publish a speech start with no end, so the hold from the
previous commit thought the user was still talking and merged all four into
one task ("查询 PR 5167 的状态" carrying the other three).

The rule the user asked for: sentences with no reply in between are one
request and wait for the user to finish; once the model has spoken to the
user, what they say next is a new request. The gate now releases a held call
the moment the model's audio starts (response.first_audio), and every speech
event wakes the wait at once instead of being seen at the next poll.

Test: the 23:42 sequence (four requests, each answered, short sounds between
them) must give four tasks; it fails without the model-audio release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…om the task table

Live test on 2026-10-11 (00:11:22 UTC): five voice tasks; the user had heard
two answers, a third had come back from the core but still waited to be read
(the user kept talking), two were with the core. get_task_status reported
openTasks = 2 (queued or started), and the model said "2 pending"; the user
counted 3. The table knew: that row had no delivery.

The tool now reports pendingTasks = tasks with no answer heard yet, split
into stillBeingWorkedOn and doneNotYetHeard, and describeVoiceTasks leads
with the same numbers. Cancelled tasks are not pending. The in-line queue
position ("N in line") is unchanged: it is a core queue question.

Test: the 00:11:22 rows give pending 3 / working 2 / doneNotHeard 1; it
fails when pending counts only what the core has not finished.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…user has left

Live test on 2026-10-11 (00:10-00:14 UTC): four results came back while the
user kept talking, so they waited in the pacer for a pause. The user closed
the page at 00:13:11. From 00:14:10 each was returned to its work call a
minute apart ("no turn after the hand-off; moving on"), recorded as heard
(injected), and never read to anyone; a result landing while no client is
connected already went to the DM, but one that was queued before the client
left did not.

The work-call hand-off now checks that the session can speak, like the
notice route does. When it cannot, the result takes the offline way: a
reconnect within a minute is spoken, otherwise one DM copy, recorded dm, and
read on the next connection by reconcile.

Test: the 00:14 sequence (result lands while the user talks, the client
leaves, the user's turn ends) must end in dm, never injected; it fails
without the check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…or the user

Live test on 2026-10-12 (00:33 UTC): the user had four tasks queued and
asked how many were pending. The table also held two tasks cleared earlier
with [no-send] results; they had a result and no delivery, so they counted
as "done but not heard" and the count was 6 for 4.

A skip-marked result that answers no other task now marks its row retired:
the core ended it with nothing for the user to hear. Retired rows are not
pending and are listed as "ended with nothing to say". The status groups
are renamed so a model reading them cannot take "1 in progress" for the
pending count: the headline is "Pending for the user: N", and the core's
groups read "the core is working on" and "queued at the core".

Tests: a retired row is not counted (unit); a [no-send] result retires its
row end to end (fails without markRetired).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The user asked to see the relay agent's table while testing, including
mid-call: which requests are open, where the core stands on each, and
whether the answer was heard. Until now the only live view was the web
client's task panel, fed by agent-api's /tasks/active (the core's task
files), which knows nothing of the table's heard column.

The voice agent now writes state/voice-task-status.json whenever the
table's content changes (checked every second): every row of the last
hours, newest first, with core state, delivery, heard, retired and a
per-row pending flag, plus the same counts get_task_status reports (one
code path, one number). The web client serves the file at /voice-tasks and
a self-refreshing page at /voice-tasks/view.

get_task_status also logs the counts it returned, so a wrong number said
out loud (2026-10-12 00:33: "one" for 4) can be laid against what the tool
gave.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er hands it over again

Live test on 2026-10-12 (01:06 UTC): PR 5167's answer was read to the end
("关于 PR #5167 的状态,目前正等待作者处理复查者提出的修改意见。"), a 213 ms sound
from the microphone counted as a user interruption, the pacer handed the
result over once more, and the user heard it twice. Same at 22:55 and
23:01 the day before. The user's rule: once it has been said, it is cleared.

The pacer now records a result spoken as soon as the model's turn that
began saying it ends, interrupted or not, and never hands it over again.
`rehand` leaves the Handoff contract. The table's heard column therefore
follows what the user heard once, which is what "pending" counts.

Test: the cut-off case records spoken and hands nothing over again
(replaces the test that encoded the re-hand).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… start has no end

Live test on 2026-10-12 (02:35 UTC): "check PR 5229" was never submitted on
its own; "check PR 5167", asked five seconds later after the model had
replied, was merged into it as one task. bodhi publishes speech.user_started
with source "provider" for the Gemini transport on every input transcription
chunk and on every interruption, and never a matching speech.user_ended
(only the OpenAI transport has a stop event). The transcription chunk
arrives after the client microphone already reported the end, so both the
submit gate and the hand-off pacer believed the user was still speaking:
the gate held the call and merged the next; the pacer held results up to
its 60 s guard after every utterance, which is the delay the user kept
seeing before an answer.

Both now count only the client's own detection (source other than
"provider"). The merged task's text is also written to be read in the
table: "<later> (the user also said, just before, cut off mid-sentence:
"<earlier>"; ...)".

Tests: a provider start holds neither a request nor a result; the two
requests of 02:35 stay two tasks. Both fail without the filter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d; a blank is asked about

Live tests on 2026-10-12 (03:08, 03:17, 03:23 UTC): "帮我查一下那个 PR 的状态"
(no PR named) was submitted as "查一下PR的状态" twice, then answered with an
invented "PR #404" from stale context. Four prompt revisions, including the
work tool's own description, did not get the voice model to stop and ask:
it decides fluently and does not pause.

The decision now lives outside the voice model. Before the relay agent
submits a request it asks a text model (gemini-3-flash, the summary model)
one question over the request and the recent words: can the core act on it
as written, or is a name, number or target missing that only the user can
supply? "ask" returns at once to the work call: nothing is submitted, and
the model is told the one question to ask; the user's answer comes back as
a new work call. "submit", no answer, an error or 2.5 s without one let the
request through: the check never blocks work.

Tests: ask → nothing submitted, the question returned, the check read the
request and the recent words; submit and a failing check → submitted; the
reply parser treats anything unclear as submit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…SON out

Probed against the live model on 2026-10-12 with the 03:08 request. The
first prompt let "查一下PR的状态" through, and every reply was cut off at
"{"action":" because gemini-3-flash spends the output budget on thinking.
Thinking is now off and the reply is JSON; the prompt asks the one question
that matters (does the request identify WHICH thing it is about) with four
examples. On seven cases the blank PR and an unnamed meeting are asked
about, named PRs, a general weekly summary and a dish recommendation go
through; 0.7-2.4 s each, so the time limit is 4 s. Each decision is logged
with its time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@liususan091219 liususan091219 changed the title fix(voice): results reach the model one at a time, after the user's turn, and count as heard only once spoken fix(voice): the relay agent's task table decides what is submitted, when results are said, and what the user heard Oct 12, 2026
…ment; src-map for work-request-check

The canary in task-bridge-spoken-block pinned the exact text of the
speech.user_started handler; 61a62cb gave it the event (its source tells
the gate whether the microphone saw it) and the stamp is unchanged. The
regex now allows an argument. docs/src-map.md gains the new module.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: changes requested. Reviewed at head f7f69841, diffed against merge-base 250f3c5b (13 files, +1256/−87). The pacer and the table are a real improvement over #5310. Five things block it: required CI is red, /voice-tasks serves request text to the LAN, there are two paths that drop a result without telling anyone, and there is no live witness at head. Each one has a small fix. Details below. Everything marked verified I ran myself in a throwaway clone. Everything marked by reading I did not run.

Blockers

B1. Required CI is red at head, from this PR's own new test. node + out-of-tree python tests fails on run 38204921608 with not ok 13 - a "not picked up" notice still waiting its turn is dropped once the task has been answered (tests/relay-agent-e2e.test.ts:293, the stale notice is not said, actual false). The same suite passes for me locally (13/13, two runs), so the test is timing-sensitive, not deterministic. By reading, here is the mechanism. The test holds the pacer by emitting speech.user_started every 50 ms against handoffConfirmMs: 200 and never emits a speech.user_ended. So speakingHold() (src/relay-agent.ts, createHandoffPacer) lapses as soon as one emit is more than 200 ms late on a loaded runner. userEndedAt is still null at that point, so userTurnOpen() returns false and the notice is handed before the result lands. CI history for this branch also shows 61a62cb8, 797662b3 and f7f69841 all failure. Please make the test deterministic: use an injected clock, or an explicit user_ended plus a fixed reply wait. Don't just widen the timings.

B2. /voice-tasks and /voice-tasks/view serve the user's request text to the whole LAN (src/web-client.ts:4443-4455). The server listens on CLIENT_HOST || '0.0.0.0' (:24, :4869). These two routes have no isLoopbackAddress / LAN_SHARE gate. The handler 140 lines above (/api/task-history, :4301-4316) states the repo's rule for exactly this data: "Task history carries owner prompts and result bodies … reject LAN callers unless the owner explicitly enabled LAN sharing." Every row's text is the owner's spoken request (up to 200 chars), for example "email Bob about …". The fix is the same 3-line gate as task-history. Dropping the CORS header (it's already absent) and escaping in the view (esc(), done) are good. The missing piece is the origin check.

B3. A proactive message can silently swallow the answer to a voice request (coveredTasks, src/relay-agent.ts:214, wired at src/live-agent-runtime.ts:248). A proactive-* result with no task id "covers" every open voice task whose 3+-digit numbers all appear in it. Once the model says that message, markSpoken records the covered tasks spoken. When the task's real result arrives, moot() → endCall(...,'delivered_separately') plus a log line (:216). Nothing is said, and nothing goes to the DM. Verified with a probe against head: store "Check the review status of PR 5302", then coveredTasks(store, "[proactive] CI failed on PR 5302 (lint).") → ['task-1'], and after it is spoken resultIsMoot(store,['task-1']) → true. Dates trigger the same thing: "Summarize the 2026 roadmap doc" is covered by "Morning briefing for 2026-10-12: …" (the (?<!\d)2026(?!\d) match succeeds before -). On this repo PR-number notifications are routine, so this is the expected case, not an edge. Matching on numbers proves the two texts mention the same thing. It does not prove one answers the other. My suggestion: only let a message cover tasks when the core attributes it, e.g. a [covers: task-…] marker through src/result_markers.py, the way [deduped:] already works. Otherwise drop covers. A covered task's own result should at least fall back to the DM rather than be dropped.

B4. replaces substring-matches every recent unheard request (RelayAgent.supersede, src/relay-agent.ts:541-555). needle = replaces.toLowerCase() with no minimum length and no anchoring. Every unheard request from the last hour that contains it is marked supersededBy, its call ends, and its result is never said (planReconcile returns 'none', and resultIsMoot is true). Verified: three unheard tasks (Check PR 5302, Summarize PR 5251 review, Email Bob the PR list) plus work({task:'Check PR 5320', replaces:'pr'}) → all three superseded. The tool description invites "51167", but the voice model fills this field, and a model that writes "PR" or "the" once drops a whole session's results. Please match whole marks (the requestMarks tokens, or a task id) and supersede at most one row, the most recent.

B5. No post-restart live witness at head (REVIEW.md lesson 15 / CONTRIBUTING). This is the voice delivery loop. The PR body's live runs are UTC 10-11 22:41, 10-12 02:54, and 03:17→04:01. The last commit that changes src/ is 797662b3 (03:51 UTC), and head f7f69841 only touches a test regex and docs/src-map.md. So the code is probably what was run live, but no comment ties a run to a sha, a restart line and a transcript. With the next push, please post one live run at the new head: sha, voice-agent restart line, and the log/transcript of (a) two results landing together being said one at a time, (b) a blank request being asked about, (c) /voice-tasks from a non-loopback host refused.

Should fix (not individually blocking)

  1. The clarification gate has no cap on how many times it asks (src/relay-agent.ts ~:494-499). After an ask, the next work call goes through the check again. An answer like "the latest one" or "the one from this morning" can be judged unidentified again, and the user gets interrogated. Allow one ask per request: if the previous call in this conversation got an ask, submit the next one regardless.
  2. Abort still submits (src/relay-agent.ts:278,289 and the check's catch → submit). waitTurnOver resolves on signal.aborted, and an aborted fetch falls through to {action:'submit'}, so a call bodhi cancelled (toolCallCancellation → abort, or the session closing) during the up-to-30 s hold or the 4 s check is still submitted. On main the submit was immediate, so this window is new. Check signal?.aborted before this.deps.submit and return without submitting. Or document why a cancelled call should still run.
  3. The meeting hold (#5319/#5324) is only checked when a result is queued, not when it is handed. deliverElsewhere checks meetingHoldsModel and then pacer.submits, but viaNotice.hand (src/live-agent-runtime.ts:166) and the retryThenDm re-submit path don't re-check it. bodhi's tryPublishSystemNotification → NotificationActor has no transcription-mode check (bodhi 0.4.5 voice-session.ts:2809, notification-actor.ts). A meeting that starts while results queue behind a 60 s confirm can hear the model speak. By reading. The fix is for hand() to return to heldForMeeting when meetingHoldsModel(session).
  4. WORK_CHECK_MODEL is an ad-hoc, undocumented env var with no off switch (src/work-request-check.ts:22). CLAUDE.md: "Don't invent an undocumented env var". This check runs on every work call, makes an external model call, and changes what gets submitted, so owners need a documented way to change the model and to turn it off (config through the repo's config path, or move the check into a skill with a manifest.json config block). On placement: the relay agent takes check as an optional dependency, which is the right shape, but the prompt/policy module is feature logic sitting in src/. It reuses GEMINI_VOICE_API_KEY (same key and quota as the live session). It fails open on a missing key, HTTP error, non-JSON body, timeout or empty reply, which is correct.
  5. The model id is unverified. gemini-3-flash-preview with thinkingConfig.thinkingBudget: 0. I did not call the API. The body's "2.3 s" is the only latency figure, from one sample. The cost on every request is up to QUIET_BEFORE_SUBMIT_MS (1.5 s) + WORK_CHECK_TIMEOUT_MS (4 s) before the core sees it. Please log p50/p95 of the check from the live runs. The 4 s timeout is untested: createGeminiWorkCheck has no test for timeout, abort, HTTP-error or non-JSON paths (only parseWorkCheck and an injected fake are tested).
  6. #5327 is closed by a decision, not fixed. #5327's bug is "a result cut off mid-answer is never said again". 4e809ae makes that the rule ("said once is said") and now records the cut-off result as spoken instead of injected. That may be the right trade (the 213 ms false cut-off), but the PR should say "#5327: won't fix, said-once rule" rather than Closes #5327. Also #5328's own constraint ("the pacer held a spoken result 49 s while room noise kept the client VAD 'speaking'") is only bounded by the 60 s speakingHold cap, which is not better than 49 s.
  7. Nits: key=${apiKey} in the URL (prefer the x-goog-api-key header so the key can't end up in a logged URL). Several new tests hang instead of failing when their fix is reverted (see below), so add a Promise.race timeout. /voice-tasks/view hardcodes lang="zh".

Discrimination: sampled 6 commits, reverting the fix and keeping the test

commit reverted at result
4e809ae said-once HEAD 61 → 1 fail (an answer the model began is said once…)
61a62cb mic-only VAD HEAD 61 → 3 fail (provider start does not hold a request / a result; cut-off join)
fe7ce86 client left HEAD e2e 13 → 8 fail (incl. the client leaves while a result waits its turn…); broad collateral
6769e39 retired HEAD 74 → 4 fail (a task the core ends with a skip-marked result is not pending…)
ae17e62 pending count ae17e62 (conflicts at HEAD) 54 → 1 fail (pending is what the user has not heard…)
3deb7af work check HEAD, if (false && verdict.action==='ask') a request with a blank in it is not submitted hangs (awaits a call that never resolves); it discriminates, but by hanging

So the claim that every fix has a test that fails when the fix is reverted holds on this sample, with the hang caveat. Counts match the body: relay-agent 61, e2e 13, voice-task-cancel 11.

Ran

tsc --noEmit clean. eslint on the changed files: 0 errors (20 warnings, CI eslint green). tsx --test relay-agent, relay-agent-e2e, meeting-input-hold, voice-task-cancel, voice-behavior-anchors, inline-tools-, task-bridge-: 275/275 pass locally. CI at head: 31 pass, 2 fail (B1), 1 skipping.

Scope

These are three concerns in one PR. (a) The pacer and task table are what #5327/#5328 are about. (b) /voice-tasks is an independent UI, and the source of B2. (c) The pre-submit LLM gate is a new feature with a new external call and its own failure modes, landed about an hour before head with one live sample. I'd land (a) here and split (b) and (c) out so each gets its own witness. If you prefer to keep them together, fixing B1–B5 is what moves my verdict.

The bodhi observation (context, no action on this PR)

Confirmed in randombet/bodhi_realtime_agent 1cc152a (0.4.5). GeminiLiveTransport calls onSpeechStarted on every inputTranscription chunk (src/transport/gemini-live-transport.ts:1741) and again on interrupted (:1770). It never calls onUserSpeechStopped; only the OpenAI (:1794) and Qwen (:711) transports do. VoiceSession turns these into speech.user_started with source:'provider' (src/core/voice-session.ts:2398-2410), and the matching speech.user_ended publisher (:2412) never fires for Gemini. Filtering to client-vad (61a62cb) is the right workaround here.

liususan091219 and others added 3 commits October 12, 2026 14:45
…e status file and page are gone

Users of the product never see the web client's /voice-tasks page, and the
table has one reader, the voice agent itself (the model reads it through
get_task_status). So the table is now an in-memory map next to the pending
task map, capped at the newest 200 rows; state/voice-tasks.json,
state/voice-task-status.json, /voice-tasks and /voice-tasks/view are removed.
add() takes an optional submission time for a row rebuilt from an older task.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ten on each change like bodhi's history stores

The table stays in the voice agent's memory (04ef8cf). The owner still
wants to watch it live, so the page and its routes are back, fed the way
bodhi writes the conversation: the table calls its onChange listeners after
each change, and a writer copies it to state/voice-task-status.json
asynchronously, one write at a time, coalescing changes that arrive during a
write. A failed write is logged, never thrown, and the table never waits.
No polling timer.

Mutation: with the listeners not called, three tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…same request is not sent twice

2026-10-12 22:10: of five requests two never reached the core. "帮我查一下
5334 的状态" went to get_task_status (the word 状态), so the relay never saw
it, and the voice model said it was queued. PR 5333 was sent twice.

- Every real user turn goes to the relay agent (onUserTurn). Once the user
  has finished speaking (turns of one request are taken together), a text
  model decides: work (sent to the core, and the voice model is told it was
  sent), none (chat, a yes/no, a task-list question, a public fact), ask
  (one question to the user), or duplicate (an open task already covers
  it). A turn the voice model sent with work itself, also one that arrives
  while the check runs, is left to that call. Any failure means none, so
  the voice model's own work call still covers the turn.
- The work-call check also gets the open tasks (sent, not answered,
  cancelled, retired or corrected, last 30 min). The same request again
  waits for the open task's result instead of being submitted.
- A bare number counts as identified when the conversation shows what the
  numbers are (22:10: "5332" in a talk about PRs was asked "PR or issue?").

Live model (gemini-3-flash, today's cases): turn check 9/9 (5334 and 5332
"的状态" -> work, "对" to the offer -> work, pending question -> none, PR5333
again -> duplicate, a blank PR -> ask, chat and a public fact -> none, a timed
reminder -> work); work check: the 5333 repeat -> duplicate, a bare 5332 ->
submit. Mutations: dropping the "voice model sent it" guard or the work-call
duplicate handling each fails a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
liususan091219 and others added 6 commits October 12, 2026 17:02
…equest, wait for the assistant's own question, and skip fragments

2026-10-12 23:53 on the test Mac: "帮我查一下那个 PR 的状态" was sent to the core as
PR 5302, a PR from a request fifteen minutes earlier, while the voice model
was rightly asking "which PR?". A noise transcribed as "A" then made the
user be asked "5302 or another?".

- Both checks: recent words supply the missing number or name only when the
  user is continuing the same request (e.g. answering "which PR?"). A PR,
  file or meeting from a different, earlier request does not fill in "那个
  PR": ask.
- Turn check: when the assistant's reply to the turn asks the user about this
  request, decide none; the answer decides. A fragment (one letter, a sound)
  is none.

Live model, today's cases plus the earlier ones: turn check 13/13 ("那个 PR"
while the assistant asks -> none; on its own -> ask; "A" -> none; "5334" as
the answer -> work; the earlier nine unchanged), work check 3/3 ("that PR"
with 5302 earlier -> ask).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce model's own question is seen at once

2026-10-12 00:06 on the test Mac: "帮我查一下那个餐馆的 menu" was sent to the core
with the blank in it while the voice model was asking "哪一个餐馆?". The
checks read conversation.log, which is now written asynchronously (#5332), so
the voice model's reply was not in it yet and the "the assistant is asking:
wait" rule never saw it.

The checks now read the last eight spoken lines from bodhi's in-memory
conversation (system lines and tool items left out), falling back to
conversation.log when it is empty.

Live model with the question visible: "那个餐馆的 menu" and "那个 PR" -> none,
4/4 over two runs.

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

# Conflicts:
#	docs/src-map.md
#	src/voice-agent.ts
…r, whole-mark corrections, deterministic tests

From yixuan-ag2's review at f7f6984:
- B1: the e2e tests that held the pacer with a 50 ms stream of speech events
  (a loaded runner lapses the hold) now open one explicit user turn and move
  on with a model turn. 4 concurrent runs under a CPU burner: 13/13 each.
- B2: /voice-tasks and /voice-tasks/view are local-only unless LAN sharing is
  on, like /api/task-history (the rows are the owner's spoken requests).
- B3: a message with no task of its own no longer stands in for a voice
  task's result because it mentions the same numbers ("CI failed on PR 5302"
  made "check PR 5302" count as heard and its real answer was dropped).
  `covers` / coveredTasks are removed.
- B4: `replaces` supersedes only the most recent unheard request carrying the
  same whole mark (a 3+ digit number, a quoted name) or the named task id;
  "pr" or "the" supersedes nothing.
- One question per request: within 2 minutes of an ask, the next call is sent
  as it is. A call bodhi cancels while it waits is not submitted.
- A result waiting its turn is not said into a meeting that began meanwhile:
  the hand and the retry path re-check the meeting hold.
- The check's model is a documented voice-agent.json key, `relayCheck`
  (false turns it off), not an undocumented env var; the API key goes in the
  x-goog-api-key header, not the URL.

Also merges main (#5332/#5333/#5334): the call-end reporter and the task
status writer are both wired.

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

2026-10-13 00:33 on the test Mac: "帮我查一下那个 PR 的状态", heard as "到船上那个
皮儿的状态", was sent as PR 5385 and 5395, the PRs of the request seven seconds
earlier. The rule only forbade filling it in from a different, earlier
request. Now: a "that X" with no number or name in what the user just said is
asked about; the only exception is the user answering the assistant's own
"which one?".

Live model: turn check 15/15 (tonight's two forms -> ask), work check 3/3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…peaking, not queued as a notification

2026-10-13 00:41 on the test Mac: the turn check decided at :16 to ask "5385
or 5395, or another PR?", but the question went through bodhi's notification
queue, sat behind the model's own answer and a noise turn, and was said at
:35, so it sounded like the third line was the one asked about.

The question now goes through an ask channel: live input (injectText, as the
meeting-ended line does) at once when the model is quiet, else when its turn
ends, at most 15 s later. Only the newest question waits.

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

Copy link
Copy Markdown
Collaborator Author

Live run after the review fixes (B5)

Build on the test Mac: branch commits through 5223aa15 (incl. the review fixes ebaf8f14, 5fdb65cd, 5223aa15) cherry-picked onto the test checkout, HEAD c296c9e4c. Restart line: 01:00:53.390 [Startup] session.start() succeeded, banner Native audio: gemini-3.8-live (googleSearch=true). Times are UTC, 2026-10-13.

(a) Two results, said one at a time. The user disconnected; both landed while nobody was listening, went to the owner DM, and were said one after the other on reconnect, with no restart in between:

01:03:15.100 [TaskBridge] Task task-1791853395085: Check the changes for 5385 and 5395.
01:03:38.653 [TaskBridge] Task task-1791853417135: Check the status of PR 5334.
01:04:55.003 [TaskBridge] Voice offline; forwarded task-1791853395085 result to the owner DM via proactive-result-…
01:04:55.004 [TaskBridge] Voice offline; forwarded task-1791853417135 result to the owner DM via proactive-result-…
01:06:06.618 [VoiceSession] Client connected (geminiActive=false)
01:06:07.933 [RelayAgent] task-1791853395085: result never heard (dm); handing it over again
01:06:07.950 [RelayAgent] task-1791853417135: result never heard (dm); handing it over again
01:06:11.417   [assistant] 关于 PR 5385 和 5395 的更改,5385 限制了开发统计信息的显示,目前被要求改动;5395 修复了代理的误发通知,
01:06:28.391   [assistant] 关于 PR 5334 的状态,该 PR 已经成功 合并进了 main 分支。

The status copy after: pending 0, both rows spoken. (The first answer was cut by a noise turn, ¿Qué?; by the said-once rule it is not repeated, and the model finished it in its next line.)

(b) A blank request is asked about, quickly. The voice model routed it to another tool; the per-turn check caught it, sent nothing, and the question was said about a second later (before 5223aa1 the same question sat in the notification queue for 19 s):

01:03:25.169   [user] 帮我查一下那个 PR 的状态。
01:03:26.958 [RelayAgent] turn check: ask (请问您是指哪一个 PR 的状态?) in 1134ms
01:03:26.958 [RelayAgent] turn not sent, asking the user: 请问您是指哪一个 PR 的状态?
01:03:27.848   [assistant] 您是指哪一个 PR 呢?

No row was added for it; the next line, "帮我查一下 5334 的状态", was submitted.

(c) The task page refuses a non-loopback host.

$ curl -s -o /dev/null -w "%{http_code}" http://xueqings-macbook-pro-2:8080/voice-tasks      # from another Mac on the LAN
403   {"error":"voice tasks are local-only"}
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/voice-tasks                   # on the test Mac
200   {"at":…,"pending":0,"working":0,"doneNotHeard":0,"tasks":[…]}

Since the review, also live on this machine: the per-turn check sent "帮我查一下 5334 的状态" when the voice model routed it to get_task_status (lost before); a repeat of an open request is not sent twice; "那个 PR" is never filled in from an earlier request; a result for a disconnected user goes to the DM.

#5327 is now stated in the body as won't fix (said-once rule), not "Closes".

— Claude Code, for liususan091219

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Independent review at head 5223aa1, since Yixuan's agent is paused until Wednesday. Read her CHANGES_REQUESTED at f7f6984 in full and verified each blocker's fix directly in the diff (not taking the commit message's word for it), plus Susan's live witness.

B1 (flaky CI test) — fixed. The e2e test's setInterval(() => session.emit('speech.user_started'), 50) polling (which lapses the 200ms pacer hold on a loaded runner, exactly as Yixuan diagnosed) is replaced with one explicit speech.user_started + speech.user_ended pair — the race is eliminated, not just narrowed. CI is green at head ("node + out-of-tree python tests" passes).

B2 (/voice-tasks LAN exposure) — fixed. Added the same 3-line isLoopbackAddress/LAN_SHARE gate as /api/task-history, verified in the diff at src/web-client.ts. Susan's live witness confirms it behaviorally: 403 from another Mac on the LAN, 200 from loopback.

B3 (silent-drop via covers) — fixed by removal, not patching. covers/coveredTasks and their number-substring matching are deleted entirely from relay-agent.ts and live-agent-runtime.ts (ids() no longer falls back to item.covers). The failure mode Yixuan demonstrated (a proactive CI-failure notice silently covering an unrelated "check PR 5302" task) can't recur because the mechanism is gone, not just made stricter.

B4 (replaces over-matching) — fixed correctly. supersede() now requires either an exact task-<id> match or every requestMarks() token (3+ digit numbers, quoted names) to match, and only supersedes the single most recent unheard candidate (.pop() on the filtered list), not every match. A bare replaces: 'pr' now has zero marks and short-circuits to "nothing corrected" — exactly the case Yixuan's repro broke.

B5 (live witness) — Susan's comment gives a real restart (banner, timestamp) and real log lines for all three cases Yixuan asked for: two queued results said one at a time after reconnect, a blank request asked about quickly, and /voice-tasks refusing a non-loopback curl. One caveat worth naming: it's commits-through-5223aa15 cherry-picked onto a separate test checkout (head c296c9e4c), not a literal checkout of this repo's 5223aa15 — the code is the same, the sha lineage isn't. Didn't block on this since the diff content is verifiable directly and matches.

Also confirmed two of the should-fixes landed: the one-ask-per-request cap (askedAt/ONE_ASK_WINDOW_MS) and an abort check before submit, plus the meeting-hold re-check in both hand() and fallback() (should-fix #3 — a result can no longer speak into a meeting that started while it waited), and the API key move to the x-goog-api-key header (nit).

Didn't independently re-run the 61/13/11-test suites myself or verify the WORK_CHECK_MODEL→relayCheck config-key move beyond reading the diff; everything above is traced to the actual lines, not inferred from the PR description. Approving — Yixuan's stated bar ("fixing B1–B5 is what moves my verdict") is met.

…ails instead of hanging

Review of #5348 (should-fix 5 and the hang nit): timeout, session abort,
HTTP error, non-JSON body, empty answer and missing key all let the
request through; the key goes in a header, never the URL.

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

Copy link
Copy Markdown
Contributor

@cla-assistant check

@liususan091219

Copy link
Copy Markdown
Collaborator Author

Point-by-point on the CHANGES_REQUESTED review at f7f69841. Head is now 0e11edd2, CI green (33 pass, 1 skipping).

Blockers

  • B1 flaky e2e: the tests now emit an explicit speech.user_started + speech.user_ended and then a model turn. They no longer depend on a 50 ms interval beating a 200 ms window.
  • B2 /voice-tasks + /voice-tasks/view: same gate as /api/task-history. A non-loopback caller gets 403 unless LAN_SHARE is on. Witnessed live: LAN 403, loopback 200.
  • B3 covers / coveredTasks: removed. A proactive message no longer marks any task spoken. Each task's own result is said, or it goes to the DM.
  • B4 replaces: matches a whole task id (task-\d+) or whole requestMarks tokens only, and supersedes at most one row, the most recent.
  • B5 live witness at head: fix(voice): the relay agent's task table decides what is submitted, when results are said, and what the user heard #5348 (comment) (sha, restart line, transcript).

Should-fix

  1. One ask per request: after an ask, the next work call within 2 min (ONE_ASK_WINDOW_MS) is submitted without a second ask.
  2. An aborted call is not submitted: signal.aborted is checked before submit, and the call returns not_sent.
  3. The meeting hold is rechecked at hand time: viaNotice.hand returns false under meetingHoldsModel, and the retryThenDm attempt holds for the meeting too.
  4. No env var anymore. The relayCheck key in voice-agent.json sets model and turns the check off with false. It is documented in voice-agent.config.json.example.
  5. Failure paths are now tested (0e11edd2): timeout, session abort, HTTP 429, non-JSON 502 body, empty answer, missing key (no call made), plus key-in-header and never-in-URL. Live latency from the test machine: work check 0.9 to 1.5 s (n=2), per-turn check p50 1.1 s, max 1.13 s (n=5). The samples are small; I'll keep logging.
  6. voice: a task result cut off mid-answer is never said again #5327: the PR body now says won't fix by decision (said-once rule), not Closes. I'm leaving the 60 s noise cap as it is, as a known bound.
  7. The key is in the x-goog-api-key header. lang="zh" is removed. The e2e waits are raced against a timer, and the blank-request unit test is raced too (0e11edd2), so a reverted fix fails instead of hanging.

Scope: kept as one PR. You said fixing B1 to B5 is what moves the verdict, and all five are done. With that, I'm dismissing the earlier CHANGES_REQUESTED so the PR can merge. Follow-ups are welcome as separate issues.

— Claude Code, verifying/reviewing for liususan091219

@liususan091219
liususan091219 dismissed yixuan-ag2’s stale review October 13, 2026 02:57

All five blockers (B1–B5) and the should-fixes are addressed at 0e11edd; point-by-point: #5348 (comment)

@liususan091219
liususan091219 merged commit d930967 into main Oct 13, 2026
34 checks passed
@liususan091219
liususan091219 deleted the fix/relay-spoken-and-paced branch October 13, 2026 02:58
yixuan-ag2 added a commit that referenced this pull request Oct 13, 2026
…waits

Since #5348 the voice model's `work` call runs through RelayAgent.invoke,
which waits for the user's turn to end and for the request check before
calling submitWorkTask. The origin, turns binding and recent context were
captured inside workTool.execute, after those waits, so a room switch
during them re-addressed the task. The turn check had the same order.

RelayAgent now takes a captureOrigin dep, calls it on entry to invoke (and
when a turn is queued for the turn check), and passes the snapshot to
submit. submitWorkTask hands it to workTool.execute, which uses it instead
of capturing its own. A cut-off call joined by the next one keeps the
earlier call's origin.

Also pins the turns-provider replacement bump: the replaced provider now
receives late words in its test, so dropping the bump fails it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
yixuan-ag2 added a commit that referenced this pull request Oct 13, 2026
…submission (#5035)

* fix(voice): retain task origin and transcript ownership across awaits

Capture the originating room, header fields and recent conversation when work is invoked, before the asynchronous watcher probe or transcription wait. Keep late-transcription polling on the captured provider and invalidate its read window when an origin, provider or logical session is rebound. Continue the task with its original address and omit unavailable speech instead of borrowing a later client's words.

The transcription wait introduced in #4866 delayed reads of mutable process-global context. Switching rooms or reconnecting while that wait was open could write another room into the durable task header and then deliver the answer there, including after a bridge restart. Capturing only the provider function would still leak when the runtime reuses a provider across client sessions.

Add nine behavioral regressions through the real work tool and file/result writers, including a fresh-process header reconstruction. All nine fail against c605dc5 and pass with this fix; 248 focused bridge/room/delegation tests pass on Node 24.13.1 and 32 origin/spoken tests pass on Node 26.6.0. Typecheck and scoped lint pass. No live owner session was restarted; real voice acceptance and Node 22 CI remain review gates. Closes #5030.

* fix(voice): a same-origin re-announce no longer drops an in-flight task's words

The context revision now moves only when the bound origin's channel or
target changes, so a client re-announcing the room it is already docked in
keeps a waiting work call's late transcription. A real switch still
releases the room first and drops it. Client disconnect calls
endVoiceClientSession, which closes the window even when no origin was
bound, keeping that invalidation now that a null-to-null set is a no-op.

Tests: same-origin re-announce (core) and through the real room binding,
a room switch through the binding, and an unbound client leaving.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(voice): a farewell in the same breath keeps the owner's words for the task

logSessionBoundary no longer moves the context revision. Both callers are
farewells said inside the live session, so "research X, bye" kept the task
but dropped the words that asked for it. The client closing afterwards
still closes the window through endVoiceClientSession. SKILL.md names
what invalidates a pending transcription and what does not.

Test: a farewell during the wait keeps the spoken block; a farewell
followed by the client leaving drops later words.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* perf(voice): reject a screen-view work call before snapshotting its context

The screen-view rejection is synchronous and reads only the task text, so
it now runs before the origin, turns and conversation-log snapshot. The
concat fast path and the dedup check stay after it: the fast path awaits
and dedup follows the watcher probe, so the snapshot must precede both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(voice): the relay agent captures a work call's origin before its waits

Since #5348 the voice model's `work` call runs through RelayAgent.invoke,
which waits for the user's turn to end and for the request check before
calling submitWorkTask. The origin, turns binding and recent context were
captured inside workTool.execute, after those waits, so a room switch
during them re-addressed the task. The turn check had the same order.

RelayAgent now takes a captureOrigin dep, calls it on entry to invoke (and
when a turn is queued for the turn check), and passes the snapshot to
submit. submitWorkTask hands it to workTool.execute, which uses it instead
of capturing its own. A cut-off call joined by the next one keeps the
earlier call's origin.

Also pins the turns-provider replacement bump: the replaced provider now
receives late words in its test, so dropping the bump fails it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
yixuan-ag2 added a commit that referenced this pull request Oct 16, 2026
…Supersede) (#5607)

* feat(relay-agent): export the supersede rule as a pure function

What changed

RelayAgent.supersede decided which earlier request a `work` call's `replaces`
corrects, and applied the decision, in one private method. The decision is now
planSupersede(), exported from src/relay-agent.ts:

  planSupersede({ taskId, replaces, rows, now, idPattern? })
    -> { action: 'supersede', id }
     | { action: 'none', why: 'names_nothing' | 'no_match' }

`rows` is what VoiceTaskStore.list() returns (oldest submission first); only
text, submittedAt, delivery and supersededBy are read (exported as
SupersedeRow). `now` is passed in, nothing is written, and the same inputs give
the same plan. `names_nothing` means `replaces` is no id and carries no mark;
`no_match` means no unheard request fits it.

RelayAgent.supersede now calls it and then does what it did before with the
answer: marks the row superseded, ends the waiting call, logs. The two "nothing
corrected" log lines and the "corrected by" line are unchanged. The rule moved,
it was not edited: same window (SUPERSEDE_WINDOW_MS), same filters (not the
correcting task, not heard, not already corrected, submitted inside the
window), same whole-mark match (requestMarks), newest match wins. The one
difference in the code path is that store.list(), a read-only sort, is now
taken before the "names nothing" check instead of after it.

Why

A second caller with its own table of requests needs the same rule, and it was
private. The cloud voice lane will vendor this file byte for byte and apply the
rule to its own rows. Copying the rule into that caller was rejected: the two
copies would drift. Taking a VoiceTaskStore as the input was rejected too: the
rule reads four fields of a row, and a store would tie the caller to this one.

The part of the rule that was specific to this table was what a task id looks
like: /^task-\d+$/ was written inline. A table keyed another way would have had
its ids read as numbers and matched against any row whose text happens to carry
the same digits. That form is now `idPattern`; it defaults to the old pattern,
and RelayAgent passes none.

The comment on supersede() cited the review of #5348 (B4), in which a bare
`replaces: 'pr'` superseded every unheard request. Comments here carry no
incident history, so the constraint now reads on planSupersede (only a whole
mark counts: a bare word like "pr" would match every unheard request), and the
incident stays cited in the test that pins it, tests/relay-agent.test.ts.

Tests

tests/relay-agent-supersede.test.ts (new, 16 tests) covers the export: an id
names exactly its row; marks must all be carried and the newest carrier wins; a
mark is a whole number; what names nothing versus what nothing matches; a
spoken, the correcting, and an already-corrected request are never picked while
an injected or DM-only one is; the window edge, a millisecond past it, and a row
with no time; a row with no text; the default id form and a caller's own; the
rows are left untouched; the table feeds it directly. Two cases drive
RelayAgent itself to pin what no existing test reached (the "no unheard request
carries" line) and that the picked request is marked, its call ended and the
line logged.

The existing relay suites are unchanged and pass as they did before the change:
  npx tsx --test --test-force-exit tests/relay-agent.test.ts         91/91
  npx tsx --test --test-force-exit tests/relay-agent-e2e.test.ts     13/13
  npx tsx --test --test-force-exit tests/relay-agent-origin.test.ts   4/4
npm run typecheck and eslint on the two changed files are clean.

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

* fix(relay-agent): planSupersede is pure for any idPattern; tests that can fail

Review of the supersede export found four gaps.

planSupersede called .test() on the caller's own RegExp. With a g or y flag
that advances lastIndex, so identical calls alternated between "named by id"
and "read for marks" (the id's digits then matched an unrelated row). It now
tests a copy with g and y removed; the caller's pattern is never touched.

The "whole number" test used 5167 against 51167, which is not a substring, so
it passed under substring matching too. It now uses 1167 and 5116, which are,
plus quoted-name cases ("release notes" must not match "Release notes v2").

RelayAgent.supersede read store.list() before Date.now(); it now takes the
clock first, as the original did, so the inclusive one-hour edge is unchanged.

A RelayAgent-level test pins the clock and task id it passes: a request older
than the window is out of reach and a correction carrying its own mark does
not supersede itself. A g/y test checks repeated calls agree.

Suites: relay-agent 91/91, e2e 13/13, origin 4/4, supersede 18/18; tsc, eslint clean.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

voice: results that land together are delivered back to back, with no pacing

3 participants