Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/web
Steps to reproduce
Use a fresh clone at the commit named under Version or commit, with its locked dependencies installed and the repository-pinned Playwright Chromium headless shell available. Save the script below as repro.sh in the clone's parent directory, then run from the clone root:
tmp=$(mktemp -d)
VERIFY_SCRATCH="$tmp" sh ../repro.sh "$PWD"
rmdir "$tmp"The script isolates HOME, TMPDIR and T3CODE_HOME under a temporary directory, temporarily copies one server test, one browser test and the generated shell rows into the clone, and removes them on exit. Exit 0 means that the defect is present and the ordinary-approval control is correct: the tests assert the observed defective count of two and the correct control count of one.
The server test calls the production orchestrator to create a parent thread, start its run and create an app-owned delegated child (delegated_task.request). It writes a pending command approval for the child through the production event sink, reads the SQL-backed thread shells, queues an ordinary follow-up on the parent with queue_after_active, and reads the shells again. A separate sequence from the same store puts an approval on the parent itself as the control. No provider process is launched. The browser test feeds those rows through the production client snapshot reducer, snapshot atoms, subagent-request rollup and startThreadNotifications, with the window unfocused and the "notifications and sound" mode set, recording Notification construction and playNotificationSound calls.
The user sequence this models: leave an app-owned delegated child's command approval pending, keep the T3 Code window unfocused with notifications and sound enabled, and queue another message on the parent. The queued message does not answer or replace the child's approval.
repro.sh
#!/bin/sh
set -eu
repo=${1:-$PWD}
case "$(uname -s)" in
Darwin) : "${PLAYWRIGHT_BROWSERS_PATH:=$HOME/Library/Caches/ms-playwright}" ;;
*) : "${PLAYWRIGHT_BROWSERS_PATH:=$HOME/.cache/ms-playwright}" ;;
esac
export PLAYWRIGHT_BROWSERS_PATH
stage=$(mktemp -d "${VERIFY_SCRATCH:?set VERIFY_SCRATCH to a private scratch directory}/repro.XXXXXX")
export HOME="$stage/userhome" TMPDIR="$stage/tmp" T3CODE_HOME="$stage/t3home"
mkdir -p "$HOME" "$TMPDIR" "$T3CODE_HOME"
export PATH="$repo/node_modules/.bin:$PATH"
server="$repo/apps/server/src/orchestration-v2/zz-verify-approval-renotify.test.ts"
browser="$repo/apps/web/src/components/zz-verify-approval-renotify.browser.test.ts"
export VERIFY_SNAPSHOTS="$repo/apps/web/src/components/zz-approval-snapshots.json"
cleanup() { rm -f "$server" "$browser" "$VERIFY_SNAPSHOTS"; rm -rf "$stage"; }
trap cleanup EXIT HUP INT TERM
cat > "$stage/generate-fixtures.py" <<'PYTHON_FIXTURE'
from pathlib import Path
out=Path(__file__).parent
root=Path(__import__('sys').argv[1])
server='''import { assert, it } from "@effect/vitest";
import { ProviderSessionId, ProviderDriverKind, CommandId, MessageId, EventId, ProjectId, ProviderInstanceId, RuntimeRequestId, ThreadId, OrchestrationV2ThreadShell } from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { writeFileSync } from "node:fs";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as EventSink from "./EventSink.ts";
import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
const instanceId = ProviderInstanceId.make("codex");
const adapter = {instanceId, driver: ProviderDriverKind.make("codex"), getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({type: "apply_on_next_turn" as const}), openSession: () => Effect.die("No provider process used")} as never;
const database = SqlitePersistence.layerMemory;
const layer = Layer.mergeAll(database, ProjectionStore.layer.pipe(Layer.provide(database)), ProviderReplayHarness.layerWithRegistry({name: "approval-renotify-verification"}, ProviderAdapterRegistry.layerFromAdapters([adapter]), {databaseLayer: database, runEffectWorker: false}));
it.effect("production queue and approval snapshots", () => Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const sink = yield* EventSink.EventSinkV2;
const instanceId = ProviderInstanceId.make("codex");
const modelSelection = { instanceId, model: "gpt-5.4" };
const parentId = ThreadId.make("parent");
let childId = ThreadId.make("child");
for (const threadId of [parentId]) yield* orchestrator.dispatch({ type: "thread.create", commandId: CommandId.make(`create-${threadId}`), threadId, projectId: ProjectId.make("project"), title: threadId, modelSelection, runtimeMode: "approval-required", interactionMode: "default", branch: null, worktreePath: null, createdBy: "user", creationSource: "web", ...(threadId === childId ? {lineage: { rootThreadId: parentId, parentThreadId: parentId, relationshipToParent: "subagent" }} : {}) });
for (const threadId of [parentId]) yield* orchestrator.dispatch({type: "message.dispatch", commandId: CommandId.make(`start-${threadId}`), threadId, messageId: MessageId.make(`message-${threadId}`), text: "Work", attachments: [], dispatchMode: {type: "start_immediately"}, createdBy: "user", creationSource: "web"});
const parentRun = (yield* projections.getThreadProjection(parentId)).runs[0]!;
yield* orchestrator.dispatch({type: "delegated_task.request", commandId: CommandId.make("delegate"), parentThreadId: parentId, parentRunId: parentRun.id, parentNodeId: parentRun.rootNodeId!, task: "Research", completionWake: "settled_only", modelSelection, runtimeMode: "approval-required", interactionMode: "default", createdBy: "agent", creationSource: "mcp"});
childId = (yield* projections.getThreadProjection(parentId)).subagents[0]!.childThreadId!;
const now = yield* DateTime.now;
for (const threadId of [parentId, childId]) {
const projection = yield* projections.getThreadProjection(threadId);
const run = projection.runs[0]!;
yield* sink.write({events: [{id: EventId.make(`running-${threadId}`), type: "run.updated", threadId, runId: run.id, occurredAt: now, payload: {...run, status: "running", startedAt: now}}]});
}
const shell = (threadId: typeof parentId) => projections.getThreadShell(threadId).pipe(Effect.map(value => { assert.isNotNull(value); return value!; }));
const idle = [yield* shell(parentId), yield* shell(childId)];
const childProjection = yield* projections.getThreadProjection(childId);
const childRun = childProjection.runs[0]!;
const request = {id: RuntimeRequestId.make("child-approval"), nodeId: childRun.rootNodeId!, providerTurnId: null, nativeRequestRef: null, kind: "command" as const, status: "pending" as const, responseCapability: {type: "live" as const, providerSessionId: ProviderSessionId.make("provider-session")}, createdAt: now, resolvedAt: null};
yield* sink.write({events: [{id: EventId.make("approval"), type: "runtime-request.updated", threadId: childId, runId: childRun.id, occurredAt: now, payload: request}]});
const pending = [yield* shell(parentId), yield* shell(childId)];
yield* orchestrator.dispatch({type: "message.dispatch", commandId: CommandId.make("queue-followup"), threadId: parentId, messageId: MessageId.make("followup"), text: "Additional context", attachments: [], dispatchMode: {type: "queue_after_active"}, createdBy: "user", creationSource: "web"});
const queued = [yield* shell(parentId), yield* shell(childId)];
assert.notEqual(pending[0].latestRunId, queued[0].latestRunId);
assert.equal(queued[0].status, "queued");
assert.equal(queued[0].pendingRuntimeRequest, null);
assert.deepEqual(pending[1].pendingRuntimeRequest, queued[1].pendingRuntimeRequest);
assert.equal(pending[1].lineage.relationshipToParent, "subagent");
assert.equal(pending[1].creationSource, "mcp");
yield* sink.write({events: [{id: EventId.make("resolve-child-approval"), type: "runtime-request.updated", threadId: childId, runId: childRun.id, occurredAt: now, payload: {...request, status: "resolved", resolvedAt: now}}]});
const controlIdle = [yield* shell(parentId), yield* shell(childId)];
const ownRequest = {...request, id: RuntimeRequestId.make("parent-approval"), nodeId: parentRun.rootNodeId!};
yield* sink.write({events: [{id: EventId.make("parent-approval"), type: "runtime-request.updated", threadId: parentId, runId: parentRun.id, occurredAt: now, payload: ownRequest}]});
const controlPending = [yield* shell(parentId), yield* shell(childId)];
yield* orchestrator.dispatch({type: "message.dispatch", commandId: CommandId.make("control-queue"), threadId: parentId, messageId: MessageId.make("control-followup"), text: "Additional context", attachments: [], dispatchMode: {type: "queue_after_active"}, createdBy: "user", creationSource: "web"});
const controlQueued = [yield* shell(parentId), yield* shell(childId)];
assert.notEqual(controlPending[0].latestRunId, controlQueued[0].latestRunId);
assert.deepEqual(controlPending[0].pendingRuntimeRequest, controlQueued[0].pendingRuntimeRequest);
const encode = Schema.encodeSync(Schema.toCodecJson(OrchestrationV2ThreadShell));
writeFileSync(process.env.VERIFY_SNAPSHOTS!, JSON.stringify({controlIdle: controlIdle.map(encode), controlPending: controlPending.map(encode), controlQueued: controlQueued.map(encode), idle: idle.map(encode), pending: pending.map(encode), queued: queued.map(encode)}, null, 2));
console.log("PRODUCTION_QUEUE", JSON.stringify({before: pending[0].latestRunId, after: queued[0].latestRunId, status: queued[0].status, childRequestId: queued[1].pendingRuntimeRequest?.id, parentRequest: queued[0].pendingRuntimeRequest}));
}).pipe(Effect.provide(layer)));
'''
(root/'apps/server/src/orchestration-v2/zz-verify-approval-renotify.test.ts').write_text(server)
PYTHON_FIXTURE
mkdir -p "$stage/fixtures/apps/server/src/orchestration-v2"
python3 "$stage/generate-fixtures.py" "$stage/fixtures"
cp "$stage/fixtures/apps/server/src/orchestration-v2/zz-verify-approval-renotify.test.ts" "$server"
cat > "$stage/browser.fixture.ts" <<'BROWSER_FIXTURE'
import type { AnyRouter } from "@tanstack/react-router";
import { OrchestrationV2ThreadShell, ThreadId, RuntimeRequestId, EnvironmentId } from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection";
import { createEnvironmentThreadShellAtoms } from "@t3tools/client-runtime/state/threads";
import { createEnvironmentSnapshotAtom, EMPTY_SHELL_STATE, applyShellStreamEvent } from "@t3tools/client-runtime/state/shell";
import snapshots from "./zz-approval-snapshots.json";
import { resolveBackgroundThreadRequest } from "./backgroundThreadAnnouncement";
import * as Option from "effect/Option";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";
import { stubGlobal } from "../../test/render";
const state = vi.hoisted(() => ({
mode: "notifications",
inApp: false,
toast: vi.fn(),
closeToast: vi.fn(),
shells: null as unknown as { set: (id: string, value: unknown) => void },
subagentRequests: null as unknown as { set: (value: unknown) => void },
navigate: vi.fn(),
settingsListeners: new Set<() => void>(),
}));
vi.mock(import("./ui/toast"), async (original) => {
const actual = await original();
return {
...actual,
toastManager: {
...actual.toastManager,
add: state.toast as never,
close: state.closeToast as never,
},
};
});
vi.mock(import("../state/shell"), async (original) => {
const actual = await original();
return {
...actual,
environmentShell: {
...actual.environmentShell,
stateValueAtom: ((id: string) => shellAtom(id)) as never,
},
};
});
vi.mock(import("../state/presentation"), async (original) => {
const actual = await original();
return {
...actual,
environmentSummaries: {
...actual.environmentSummaries,
get environmentIdsAtom() {
return environmentIdsAtom as never;
},
},
};
});
vi.mock(import("../state/threads"), async (original) => {
const actual = await original();
return {
...actual,
environmentThreadShells: {...actual.environmentThreadShells, pendingSubagentRequestsAtom: ((id: EnvironmentId) => realThreads.pendingSubagentRequestsAtom(id)) as never},
};
});
vi.mock(import("../hooks/useSettings"), async (original) => ({
...(await original()),
subscribeClientSettings: ((listener: () => void) => {
state.settingsListeners.add(listener);
return () => state.settingsListeners.delete(listener);
}) as never,
getClientSettings: (() => ({
notificationMode: state.mode,
inAppNotificationsEnabled: state.inApp,
})) as never,
}));
vi.mock(import("../threadNotifications"), async (original) => ({
...(await original()),
playNotificationSound: vi.fn(),
unlockNotificationAudio: vi.fn(),
setNotificationBadge: vi.fn(),
}));
import { playNotificationSound } from "../threadNotifications";
import { startThreadNotifications } from "./ThreadNotificationCoordinator";
let registry: AtomRegistry.AtomRegistry;
const shellAtom = Atom.family((_id: string) => Atom.make<any>(EMPTY_SHELL_STATE));
const environmentIdsAtom = Atom.make<ReadonlyArray<string>>([]);
const environmentId = EnvironmentId.make("one");
const snapshotAtom = createEnvironmentSnapshotAtom(Atom.family((id: EnvironmentId) => Atom.map(shellAtom(id), AsyncResult.success)));
const catalogValueAtom = Atom.make({isReady: true, entries: new Map([[environmentId, {target: new PrimaryConnectionTarget({environmentId, label: "test", httpBaseUrl: "https://example.test", wsBaseUrl: "wss://example.test"}), profile: Option.none(), enabled: true}]])});
const realThreads = createEnvironmentThreadShellAtoms({catalogValueAtom, snapshotAtom: snapshotAtom as never, hiddenThreadTagsAtom: () => Atom.make(null)});
state.shells = { set: (id, value) => registry.set(shellAtom(id), value) };
class TestNotification extends EventTarget {
static permission = "granted";
static sent: TestNotification[] = [];
close = vi.fn();
get tag() {
return this.options.tag ?? "";
}
constructor(
readonly title: string,
readonly options: NotificationOptions,
) {
super();
TestNotification.sent.push(this);
}
}
const decode = Schema.decodeUnknownSync(Schema.toCodecJson(OrchestrationV2ThreadShell));
const stages = Object.fromEntries(Object.entries(snapshots).map(([key, rows]) => [key, rows.map(row => decode(row))]));
function update(rows: typeof stages.idle) {
let snapshot = registry.get(snapshotAtom("one")) ?? {schemaVersion: 1, snapshotSequence: 0, projects: [], threads: rows, archivedThreads: []};
for (const thread of rows) snapshot = applyShellStreamEvent(snapshot, {kind: "thread.updated", location: "active", sequence: snapshot.snapshotSequence + 1, thread});
state.shells.set("one", {status: "live", snapshot: Option.some(snapshot)});
}
function setup(initial = stages.idle) {
vi.clearAllMocks();
const stack = new AsyncDisposableStack();
registry = AtomRegistry.make();
const testRegistry = registry;
stack.defer(() => testRegistry.dispose());
state.mode = "notifications-and-sound";
state.inApp = false;
state.settingsListeners.clear();
registry.set(environmentIdsAtom, ["one"]);
update(initial);
TestNotification.permission = "granted";
TestNotification.sent = [];
stack.use(stubGlobal("Notification", TestNotification));
stack.use(vi.spyOn(window, "focus").mockImplementation(() => undefined));
stack.use(vi.spyOn(document, "hasFocus").mockImplementation(() => false));
stack.use(
vi
.spyOn(document, "visibilityState", "get")
.mockImplementation(() => "visible" as DocumentVisibilityState),
);
const router = {
state: { matches: [{ params: {} }] },
navigate: state.navigate,
subscribe: () => () => {},
} as unknown as AnyRouter;
return {
start: () => {
stack.defer(startThreadNotifications(testRegistry, router));
},
[Symbol.asyncDispose]: () => stack.disposeAsync(),
};
}
const titles = () => TestNotification.sent.map(({ title }) => title);
it("production: ordinary approval control", async () => {
await using app = setup(stages.controlIdle); app.start();
update(stages.controlPending);
update(stages.controlQueued);
console.log("CONTROL", JSON.stringify({titles: titles(), soundCalls: vi.mocked(playNotificationSound).mock.calls.length}));
expect(playNotificationSound).toHaveBeenCalledTimes(1);
expect(titles()).toEqual(["Approval needed"]);
});
it("production: subagent approval repeats when follow-up is queued", async () => {
await using app = setup(); app.start();
update(stages.pending);
const parent = registry.get(realThreads.threadShellAtom({environmentId, threadId: ThreadId.make("parent")}))!;
const resolved = resolveBackgroundThreadRequest(parent);
expect(parent.pendingSubagentRequest).toBe("approval");
expect(resolved).toEqual({request: "approval", requestId: null});
expect(titles()).toEqual(["Approval needed"]);
update(stages.queued);
console.log("SUBAGENT", JSON.stringify({resolved, titles: titles(), soundCalls: vi.mocked(playNotificationSound).mock.calls.length, latestRun: stages.queued[0].latestRunId, status: stages.queued[0].status}));
expect(titles()).toEqual(["Approval needed", "Approval needed"]);
expect(playNotificationSound).toHaveBeenCalledTimes(2);
});
BROWSER_FIXTURE
cp "$stage/browser.fixture.ts" "$browser"
cd "$repo"
printf '%s\n' '== Production orchestrator queue and shell projection =='
vp test run apps/server/src/orchestration-v2/zz-verify-approval-renotify.test.ts --silent=false
printf '%s\n' '== Production client rollup and notification coordinator, failure and control =='
cd apps/web
vp test run --project browser src/components/zz-verify-approval-renotify.browser.test.ts --silent=false
printf '%s\n' 'DEFECT PRESENT: unchanged subagent approval announced twice; ordinary approval once.'Expected behavior
One pending approval is announced once while follow-up messages queue, including when the request comes from a delegated child and is shown on its parent. #25866 (#25866) states this contract in its title, and its Verification section names "an approval raised by a subagent and shown on its parent thread" as a case it only traced in code. The existing own-request regression test pins once-per-request behavior across queued runs: badge test. A queued run does not answer or replace the child's request, so queueing a parent message should not turn that unchanged approval into a new attention request.
A child approval legitimately has no request ID on the parent's presentation; the request resolver returns requestId: null for it: request resolution. The sibling screen-reader announcer in the same file compares request kind and ID without run identity.
Actual behavior
Summary. When an app-owned delegated child has a command approval pending and that approval is rolled up onto its parent thread, queueing another message on the parent announces the unchanged approval again: a second Approval needed notification is constructed and the input sound is requested again. An approval raised on the parent itself is announced only once across the same queue operation. Merged #25866 made a pending approval announce once per request rather than once per queued message and named the subagent-on-parent case in its intended scope, but left that case untested, and CodeRabbit's final review comment on #25866 already noted that "Subagent approvals without IDs can still repeat as before". This report is a reproduced follow-up to that known gap, with a failing case and an ordinary-approval control driven through the production server queue, client rollup and notification coordinator. It uses synthetic provider events at the real event sink; no live provider, native OS notification display, sound playback or mobile push was exercised.
Observed at the production notification coordinator: the unchanged child approval constructs two Approval needed notifications and requests the sound twice, once when it first becomes pending and again when the follow-up becomes the parent's latest run. The ordinary-approval control constructs one notification and requests one sound across the same kind of queue operation. The server test observes the new parent run in queued status, not executing, while the child's pending request is unchanged and the parent's own pending request is null.
The client rollup flags a live app-owned subagent's parent for approval: rollup and guards. The coordinator keys a request that has an ID by status and request ID, but keys this ID-less child approval by latest run and status: attention key. The ID-less branch of the gate treats that changed key as fresh: gate. Hypothesis, not verified: this remaining run-based identity bypasses the unchanged-approval deduplication when the approval is rolled up from a child. The existing logic test permits an ID-less subagent request to re-announce after an intervening own request, the interleaving tradeoff described in #32719; it does not cover re-announcement caused only by queueing a run.
The Notification recorder observes construction, not native OS presentation. Existing per-thread tags replace and close the previous notification, so this report does not claim two simultaneously visible native alerts or a growing badge. The sound recorder observes calls, not audible playback.
Evidence
- Expected source: fix(web): a pending approval is announced once, not once per queued message #25866 title and Verification section (subagent-on-parent case in scope, untested), and
apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tslines 263-280 (unchanged own request not re-announced when the latest run changes). - Failure source: observed output of
repro.sh(under Logs below), with the call paththreadShell.tsrollup (lines 48-146) →ThreadNotificationCoordinator.tsxattention key (lines 240-281) →ThreadNotificationCoordinator.logic.tsgate (lines 44-61). - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
Provider request and run lifecycle events are synthetic fixtures written through the real event sink; no real model or provider process was used. The browser test replaces only the Notification API and the sound function with recorders and supplies client settings and a shell transport atom; it does not mock the rollup result, request resolver, status resolver, notification gate or coordinator. Native OS notification display, audible output, Electron-specific behavior, provider-native subagents, linked-environment delegation and mobile push were not exercised.
Current main: on upstream main at 1e438c8f806c361943f6f20ed5b1a5cc7ec70e4e, the coordinator, gate and client rollup were byte-identical and the request resolver changed only an import; the server half of the repro passed there with the same queue invariant. The browser half could not load on that revision because it newly imports gt-react, which the baseline dependency install lacks, so no current-main browser result is claimed. None of those files changed between that revision and main at 2de337003dd8dec7d33d117175bca654fa2a006f.
Restoration check
With the repro above, the subagent case passes when queueing the follow-up leaves the notification titles at ["Approval needed"] and the sound call count at 1 while the child's approval stays pending. The ordinary-approval control must still announce exactly once, and a fresh approval after the parent stops waiting must still be announced. The repro's assertions encode the current defective count, so they must be inverted to serve as the passing check.
References
- Merged fix(web): a pending approval is announced once, not once per queued message #25866 (merge commit
b859968cabd38023d30cd3a7b1a5b3603355b725) and its issue Pending Claude approval is re-notified on every subsequent tool call (≈40 notifications for one request); orchestrator gets no signal it is blocked #24882: once-per-pending-approval deduplication. CodeRabbit's final review on fix(web): a pending approval is announced once, not once per queued message #25866 (fix(web): a pending approval is announced once, not once per queued message #25866 (comment)) already says ID-less subagent approvals can still repeat; this report adds the reproduced queue/rollup path and control. - Related, not duplicates: [Bug]: A second request right after the first in the same run doesn't notify #32711 and merged fix(web): notify when a new request replaces the pending one in a run #32719 concern a different request replacing the first; [Bug]: Parent gets a "Thread completed" notification when its async delegate finishes, then resumes Working #32113 (closed as a duplicate of open Completion alert fires when a delegated task finishes, though the parent is about to continue #31087) and [Bug]: A Claude Monitor wakes the agent on every event and each wake fires "Thread completed" with sound (47 notifications in 16 minutes from a thread that was only waiting) #32497 concern completion alerts during continuations; open [Bug]: request_secret from a delegated agent sends no desktop notification or phone push #33584 and fix(secrets): notify when a delegated agent asks for a secret #33585 concern missing delegated secret-request notifications; open feat(threads): keep recurring work in Background until it needs attention #31331 changes the attention key for background threads only and keeps the run ID for ordinary threads.
Impact
Minor bug or occasional failure
Version or commit
d9805b2 (includes #25866); notification code unchanged on main at 2de3370
Environment
macOS arm64, Node v26.12.1, Vitest 5.0.5, Playwright 1.64.1 (Chromium headless shell build 1248). Synthetic provider events through the production event sink; no live provider, native OS notification display, sound playback or mobile push.
Logs or stack traces
PRODUCTION_QUEUE {"before":"run:thread:parent:ordinal:1","after":"run:thread:parent:ordinal:2","status":"queued","childRequestId":"child-approval","parentRequest":null}
CONTROL {"titles":["Approval needed"],"soundCalls":1}
SUBAGENT {"resolved":{"request":"approval","requestId":null},"titles":["Approval needed","Approval needed"],"soundCalls":2,"latestRun":"run:thread:parent:ordinal:2","status":"queued"}
DEFECT PRESENT: unchanged subagent approval announced twice; ordinary approval once.Screenshots, recordings, or supporting files
No response
Workaround
Answering the child's pending approval before queueing more messages on its parent avoids the repeat, since no pending approval remains to re-announce; alternatively, the repeats can be ignored because they carry no new request.
Activity
juliusmarminge commentedon Nov 12, 2026
I tried to reproduce this on the current build and could not.
Commit origin/main b68aedf. Did not run the full app: getting an app-owned delegated child to raise a command approval needs a real provider turn. Instead I added a case to apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts, which runs the production startThreadNotifications coordinator in real Chromium with an unfocused window and the notifications mode on. The parent thread runs on turn-1, the pending-subagent-requests atom gets {thread: 'approval'} (and 'user_input' in a second case), then the parent shell's latest run moves to turn-2 and turn-3 the way queued follow-ups move it. Result: 3 'Approval needed' (and 3 'Input needed') Notifications for one unchanged pending request; 1 was expected. The thread's own pending requests in the same file announce once (#25866). This matches the reporter's server-plus-browser fixture (2 vs 1).
Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.