Repository navigation
[Bug]: Completion sound fires when subagents or monitors finish, even though the agent resumes working #13625
Description
Activity
Triage
Verdict: Bug, still present on
main(e3e7cc3fc2, package0.0.42). The report matches the code. The completion sound, toast, and desktop notification share one trigger, and that trigger treats the gap after background work ends as a finished run. This is not #5518, and #12903 does not fix it.What the report gets right
Web alerts come from
ThreadNotificationCoordinator. It asksresolveSidebarThreadStatusfor the thread, and it only records a completion timestamp while that status isreadyandlatestTurniscompleted. A newer timestamp than the one it stored last time is a completion alert. Sound plays for that alert before the toast and desktop-notification checks, so all three are the same event.let status = resolveSidebarThreadStatus(thread); if (status === "ready" && thread.latestTurn?.state === "error") status = "failed"; const prior = previous.current.get(thread.id); // ... const completedAt = Date.parse(thread.latestTurn?.completedAt ?? ""); const completion = status === "ready" && thread.latestTurn?.state === "completed" && Number.isFinite(completedAt) ? completedAt : (prior?.completion ?? null); next.set(thread.id, { attention, completion }); if (!prior || thread.archivedAt !== null) continue; const kind = attention && attention !== prior.attention ? "input" : completion !== null && (prior.completion === null || completion > prior.completion) ? "completion" : null;
resolveSidebarThreadStatusalready reportsworkingormonitoringwhilebackgroundLivenessis set, after the session itself has leftrunning. So nothing fires during the wait. While that status holds, the coordinator also does not store the waiting turn'scompletedAt. It keeps the previous marker.if (thread.session?.status === "running" || thread.session?.status === "starting") { return "working"; } if (thread.session?.status === "error") { return "failed"; } if (thread.backgroundLiveness === "working") { return "working"; } if (thread.backgroundLiveness === "monitoring") { return "monitoring"; } return "ready";
Claude opens the gap the report describes. The parent
resultcompletes the turn and sets the session back toready(ClaudeAdapter, around theturn.completedemission). Live tasks stay in the in-memory liveness set. When a subagent or monitor finishes,task_notificationbecomestask.completed, and ingestion drops that task immediately:// Sidebar background liveness: fed from the same lifecycle stream, // read by the shell query at mapping time (no persistence). switch (event.type) { case "task.started": case "task.progress": case "task.updated": case "task.completed": { // ... threadBackgroundLiveness.recordTaskLiveness({ threadId: thread.id, taskId: payload.taskId, taskType: payload.taskType, status: payload.status, agentId: payload.agentId, kind: event.type === "task.started" ? "started" : event.type === "task.progress" ? "progress" : event.type === "task.updated" ? "updated" : "completed", });
kind: "completed"always removes the task. The same event appends thread activity, so the shell is rebuilt withbackgroundLivenesscleared and the session stillready. The waiting turn'scompletedAtis now newer than the stored marker, and the alert fires. The wake-up turn does not exist yet. The next parentassistantmessage is what auto-starts it (claude/synthetic-turn-startinhandleAssistantMessage). That message arrives only after the model starts responding, so the ready snapshot is a real shell update, not a single-frame race. When that follow-up turn later completes, its newercompletedAtfires the second, real alert.Why #5518 and #12903 are a different moment
#5518 is the alert at the start of the wait, when the parent turn settles and subagents are still running. Web already suppresses that, because liveness keeps the status off
ready.#12903 is open and conflicting. It holds unread state (
hasUnseenCompletion) and relay/mobile awareness in a running phase whilebackgroundLivenessis set. Its web test then expects the toast as soon asbackgroundLivenessbecomes null, with no follow-up turn. That is the snapshot this report is about. Landing that test would lock the false alarm in as the expected web behavior.On current
main, relay and mobile do not have this same gap yet.resolveThreadAwarenessPhasenever readsbackgroundLiveness, andresolveThreadListV2Statusonly looks at session status. A running-to-completed awareness publish is not deferred, so T3 Connect and mobile still alert when the parent turn completes, which is #5518. #12903 would move that alert to liveness clear, and then they would share this gap.apps/mobile/src/features/threads/threadPresentation.tsin that PR is not a file onmain.hasUnseenCompletionalso ignores liveness, so the sidebar can already mark the thread unseen when the parent turn completes. That is the unread half of #5518, not this sound.What a fix has to change
The alert has to wait for the follow-up turn when a finished task is delivered back into the provider and the provider is going to resume. Remembering the waiting turn's
completedAton the client without playing a sound would hide this gap, and it would also hide a real completion when background work ends and nothing resumes. The signal is the handoff, not a debounce. Claude already has it:task_notificationis the delivery, andturn.startedfrom the next assistant message is the resume. The shell must not read asreadybetween those two.A run that actually ends when the last background task ends should still alert once. Approvals, questions, and failures should still alert immediately.
Disposition
Leave #13625 open. Add
bugandaccepted. Do not close it as a duplicate of #5518. Do not treat #12903 as the fix.- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.acceptedfeature request acceptedfeature request acceptedvia-triageFiled through npx t3 triageFiled through npx t3 triage
on Sep 25, 2026 - added a commit that references this issue
on Sep 28, 2026 Note
Opus 5.5 responding on behalf of @tris203
This also happens with T3's own
delegate_taskon orchestrator V2, through a path the triage above does not cover. Checked onmain@e22c880434, from source; not reproduced under a debugger.Repro: enable notification sound, have an agent call
delegate_taskin async mode (a review loop is an easy way) and end its turn. When the child finishes, the completion sound plays and the parent immediately starts its wake run. Each review round repeats it.Cause:
finalizeAppOwnedSubagentcommits the child's result and marks the parent'ssubagentturn item terminal, which empties the pending-background roster. The parent wake is offered afterwards, as a separate step (offerDelegatedCompletionDelivery→continuationRequests.offer). Between the two, the parent shell isreadywith a completed latest run, andThreadNotificationCoordinatortreats that as a new completion.Unlike the Claude-native case, the server already knows at commit time that a wake is coming (
completionPlan.offeris true), so the parent could stay non-readyacross the handoff without any provider signal. A fix scoped toClaudeAdapterV2would miss this path.Same false alert with T3-owned delegated tasks (
delegate_task), not just Claude subagents and monitors. Data from 0.0.46-nightly.20261004 (macOS, Claude parent delegating to Codex):- When the last delegated child finishes and the parent is idle, the parent's subagent turn item completes before its wake run exists. The gap was 54 of 54 cases since Oct 1: average 79 ms, 24 over 50 ms, max 720 ms. Gaps over the shell stream's 50 ms coalescing window can show the thread as
ready, with a Done badge and completion alert, before the wake turn starts. - The alert is not retracted when the thread resumes.
ThreadNotificationCoordinatorclears notifications on focus, removal or settings changes only (ThreadNotificationCoordinator.tsx:36-73), so the stale "completed" notification outlives the briefreadystate.
Possible server-side fix, matching the issue's suggestion:
derivePendingBackgroundWorkcould hold while an app-owned subagent'scompletionDeliveryispending/claimed.delegatedTaskProgressalready treats that state as owed work (SubagentProjection.ts:235-242). Separately, the coordinator could retract a thread's completion notification when that thread goes back to working.- When the last delegated child finishes and the parent is idle, the parent's subagent turn item completes before its wake run exists. The gap was 54 of 54 cases since Oct 1: average 79 ms, 24 over 50 ms, max 720 ms. Gaps over the shell stream's 50 ms coalescing window can show the thread as
Before submitting
Area
apps/web
Steps to reproduce
Expected behavior
No completion sound, toast, or desktop notification while the run is still going. The agent picks the work back up as soon as the background work finishes, so nothing is ready for the user yet. The completion alert should fire once, when the follow-up turn actually settles.
Actual behavior
The completion sound (and toast / desktop notification) fires the moment the subagents or the monitor finish, even though the agent immediately resumes working. The alert is a false alarm, and the user gets a second, real one when the follow-up turn ends.
Cause, as far as I can tell:
ThreadNotificationCoordinatorresolves the thread throughresolveSidebarThreadStatus, which reportsworking/monitoringwhilebackgroundLivenessis set, so nothing fires during the wait. When the background work ends,backgroundLivenessclears before the provider starts the wake-up turn. For that short gap the thread reads asreadywith a completedlatestTurn. That turn'scompletedAt(from when the wait began) is newer than the stored completion, so the coordinator plays the completion alert. The new turn starts right after.Related but different: #5518 / #12903 cover the alert at the start of the wait. #12903 holds completion back while background work is live, but its web test expects the alert to fire right when background work settles, which is exactly the moment described here. Relay / mobile awareness looks like it has the same gap, since it keys off the same state.
A possible fix is for the server to keep the thread in a working state through the handoff when finished background work is going to wake the agent (it knows when a background result is delivered back to the provider), rather than a client-side debounce.
Impact
Minor bug or occasional failure
Version or commit
main @ e3e7cc3
Environment
Linux, Claude Code provider (subagents and background monitors)
Workaround
None, other than turning notification sounds off.