Persisted parent notices omit child-turn initiator distinctions
2026-09-30 · Bug · Medium priority · Medium effort
REPRODUCED · High confidence for notice construction and persistence. No actual parent or child agent was started; downstream agent interpretation is not tested.
Expected and actual
Expected: a parent notice about a completed child turn carries enough provenance to distinguish direct user input from input sent by the parent. Actual: real persisted child request events contain different initiator/sender values, but the real notification service produces equivalent persisted parent notice content and structured system metadata after normalizing only synthetic entity identifiers.
Each clean run exercises seven isolated scenarios: direct-user, parent-agent, other-agent and system initiation; a changed-output control; a two-child user/parent batch; and a two-child parent/parent batch. All four single-initiator payloads are identical. The two batch payloads are identical. Changing the completed output changes the single notice, showing that the fixture observes real content differences rather than an unconditional mock result. The final expected-provenance-distinction assertion fails in both clean runs after all controls pass.
The database is real migrated SQLite. Synthetic request, turn-start, accepted-input, output and completion events are persisted before calling the production notification service. A controlled pending-interaction predicate returns true so the actual parent-system-message path stores a queued notice without dispatching work. The notice renderer, taxonomy builder, execution-settings resolution and queue writer are not mocked. Fake timers advance the two-second batching window; the queue is read from SQLite afterward.
Root cause and proposed fix
The request event schema stores source, initiator and senderThreadId. Notification item construction reads completed output and active workflow count but does not resolve the accepted request’s provenance. Structured outcomes contain child ID, status and optional interruption details; they do not carry initiation context. The content renderer uses those notification items. The real queue writer then persists the resulting content and system notice, without adding the child request’s provenance.
Proposed fix: resolve accepted request provenance for the completed turn, carry it into single and batched notification data, and render a concise distinction for direct user input versus parent or other-agent input. Specify behavior for multiple accepted inputs, retries and missing/malformed legacy provenance; retain those as explicit regression cases. The current fixture does not establish the correct policy for these additional cases. No production fix or PR was created.
Trusted base and environment
Fetched public origin/main: facb6c161d9915d2517ed15f6101e4295cf85da1. Linux x86_64, Node 24.19.0, pinned pnpm 9.15.0, Vitest 4.1.1. Two clean detached checkouts used separate frozen dependency installations and caches with shared downloads. The forced Turbo graph ran upstream generation/build tasks. Each scenario creates a fresh harness with migrated SQLite and temporary data. All request text, output, thread identities and sessions are synthetic. The fixture uses existing repository test helpers and dependencies only.
Exact repeatable steps
Save the complete test below at apps/server/test/threads/issue4424.test.ts in both checkouts. Use Node 24.19.0 and pnpm 9.15.0. These commands normalize only local workdir/cache paths. Expect exit code 1 at the final provenance-distinction assertion after structured evidence is printed. No live bb server or model credentials should be supplied.
WORK=$(mktemp -d) git clone https://github.com/get-bb/bb.git "$WORK/base" git -C "$WORK/base" worktree add --detach "$WORK/first" facb6c161d9915d2517ed15f6101e4295cf85da1 git -C "$WORK/base" worktree add --detach "$WORK/second" facb6c161d9915d2517ed15f6101e4295cf85da1 # Save the complete test below in both checkouts. cd "$WORK/first" npm_config_cache="$WORK/npm-first" npm_config_devdir="$WORK/node-gyp-first" XDG_CACHE_HOME="$WORK/cache-first" pnpm install --frozen-lockfile --store-dir "$WORK/dependency-store" pnpm exec turbo run test --filter=@bb/server --force -- issue4424.test.ts --silent=false # Same-agent second clean reproduction: cd "$WORK/second" npm_config_cache="$WORK/npm-second" npm_config_devdir="$WORK/node-gyp-second" XDG_CACHE_HOME="$WORK/cache-second" pnpm install --frozen-lockfile --store-dir "$WORK/dependency-store" pnpm exec turbo run test --filter=@bb/server --force -- issue4424.test.ts --silent=false
import {listEvents,listQueuedThreadMessages} from "@bb/db";
import {threadScope,turnScope,encodeClientTurnRequestIdNumber,turnRequestEventDataSchema,type ThreadTurnInitiator} from "@bb/domain";
import {it,expect,vi} from "vitest";
import {withTestHarness} from "../helpers/test-app.js";
import {seedThreadFixture,seedThread,seedEvent,seedStoredEvent,seedThreadRuntimeState} from "../helpers/seed.js";
import {queueChildThreadTurnNotificationBestEffort} from "../../src/services/threads/child-thread-notifications.js";
async function scenario(initiators:("user"|"parent"|"other"|"system")[],output="Synthetic completed result"){
return withTestHarness(async h=>{
const {thread:parent,project,environment}=seedThreadFixture(h);
seedThreadRuntimeState(h.deps,{threadId:parent.id,environmentId:environment.id,providerThreadId:"synthetic-parent-provider"});
const other=seedThread(h.deps,{projectId:project.id,environmentId:environment.id,title:"Synthetic other"});
const ids:Record<string,string>={[parent.id]:"PARENT",[other.id]:"OTHER",[project.id]:"PROJECT",[environment.id]:"ENVIRONMENT"};
const persisted=[];
vi.spyOn(h.deps.pendingInteractions,"hasTurnBoundPendingThreadInteraction").mockReturnValue(true);
const errors=vi.spyOn(h.deps.logger,"error");
vi.useFakeTimers();
try{
for(const [index,variant] of initiators.entries()){
const child=seedThread(h.deps,{projectId:project.id,environmentId:environment.id,parentThreadId:parent.id,title:"Synthetic child "+index});ids[child.id]="CHILD"+index;
const requestId=encodeClientTurnRequestIdNumber({value:1}),turnId="synthetic-turn",providerThreadId="synthetic-provider";
const initiator:ThreadTurnInitiator=variant==="parent"||variant==="other"?"agent":variant;
const senderThreadId=variant==="parent"?parent.id:variant==="other"?other.id:null;
seedEvent(h.deps,{threadId:child.id,environmentId:environment.id,providerThreadId,sequence:1,type:"client/turn/requested",scope:threadScope(),data:{direction:"outbound",requestId,source:"tell",initiator,senderThreadId,input:[{type:"text",text:"Synthetic input"}],target:{kind:"new-turn"},request:{method:"turn/start",params:{}},execution:{model:"gpt-5",serviceTier:"default",reasoningLevel:"medium",permissionMode:"full",source:"client/turn/requested"}}});
seedEvent(h.deps,{threadId:child.id,environmentId:environment.id,providerThreadId,sequence:2,type:"turn/started",scope:turnScope(turnId),data:{providerThreadId}});
seedEvent(h.deps,{threadId:child.id,environmentId:environment.id,providerThreadId,sequence:3,type:"turn/input/accepted",scope:turnScope(turnId),data:{providerThreadId,clientRequestId:requestId}});
seedStoredEvent(h.deps,{threadId:child.id,environmentId:environment.id,providerThreadId,sequence:4,type:"item/completed",scope:turnScope(turnId),itemId:"synthetic-output",itemKind:"agentMessage",data:{item:{id:"synthetic-output",type:"agentMessage",text:output}}});
seedEvent(h.deps,{threadId:child.id,environmentId:environment.id,providerThreadId,sequence:5,type:"turn/completed",scope:turnScope(turnId),data:{status:"completed"}});
const event=listEvents(h.db,{threadId:child.id}).find(e=>e.type==="client/turn/requested");expect(event).toBeDefined();const data=turnRequestEventDataSchema.parse(JSON.parse(event!.data));expect(data.initiator).toBe(initiator);expect(data.senderThreadId).toBe(senderThreadId);persisted.push({initiator:data.initiator,senderThreadId:data.senderThreadId});
await queueChildThreadTurnNotificationBestEffort(h.deps,{childThread:child,parentThreadId:parent.id,turnStatus:"completed"});
}
await vi.advanceTimersByTimeAsync(2000);
const rows=listQueuedThreadMessages(h.db,parent.id);expect(errors).not.toHaveBeenCalled();expect(rows).toHaveLength(1);
const row=rows[0];expect(JSON.parse(row.waitingOn!)).toEqual({kind:"interaction"});expect(row.systemNotice).not.toBeNull();
let serialized=JSON.stringify({content:JSON.parse(row.content),systemNotice:JSON.parse(row.systemNotice!)});let provenance=JSON.stringify(persisted);
for(const [id,name] of Object.entries(ids)){serialized=serialized.split(id).join(name);provenance=provenance.split(id).join(name);}
return {persisted:JSON.parse(provenance),notice:JSON.parse(serialized)};
}finally{vi.useRealTimers();vi.restoreAllMocks();}
});
}
it("retains child initiator distinctions in persisted parent notice payloads",async()=>{
const user=await scenario(["user"]),parent=await scenario(["parent"]),system=await scenario(["system"]),other=await scenario(["other"]),changed=await scenario(["user"],"Different synthetic result");
const mixedBatch=await scenario(["user","parent"]),parentBatch=await scenario(["parent","parent"]);
expect(user.notice).toEqual(parent.notice);expect(system.notice).toEqual(parent.notice);expect(other.notice).toEqual(parent.notice);expect(changed.notice).not.toEqual(user.notice);expect(mixedBatch.notice).toEqual(parentBatch.notice);
console.log("ISSUE4424",JSON.stringify({user,parent,system,other,changedOutputControlDistinct:true,mixedBatch,parentBatch}));
expect(user.notice).not.toEqual(parent.notice);
},30000);
Same-agent second clean reproduction: actual evidence
The same agent personally repeated the identical final test in the second clean checkout at the same base with fresh state. Both structured outputs match exactly after the test’s documented entity-ID normalization. Both runs reach the final failed expected-distinction assertion. This is a same-agent second clean reproduction, not independent verification. PARENT, OTHER, PROJECT, ENVIRONMENT and CHILD identifiers below replace randomly generated synthetic IDs; no content or provenance values are replaced.
first structured output and runner summary
{
"user": {
"persisted": [
{
"initiator": "user",
"senderThreadId": null
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\n@thread:CHILD0 completed:\n\nSynthetic completed result",
"mentions": [
{
"start": 13,
"end": 35,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
}
]
}
],
"systemNotice": {
"kind": "child-completed",
"subject": {
"kind": "thread",
"threadId": "CHILD0",
"threadName": "Synthetic child 0",
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
}
]
}
}
}
},
"parent": {
"persisted": [
{
"initiator": "agent",
"senderThreadId": "PARENT"
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\n@thread:CHILD0 completed:\n\nSynthetic completed result",
"mentions": [
{
"start": 13,
"end": 35,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
}
]
}
],
"systemNotice": {
"kind": "child-completed",
"subject": {
"kind": "thread",
"threadId": "CHILD0",
"threadName": "Synthetic child 0",
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
}
]
}
}
}
},
"system": {
"persisted": [
{
"initiator": "system",
"senderThreadId": null
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\n@thread:CHILD0 completed:\n\nSynthetic completed result",
"mentions": [
{
"start": 13,
"end": 35,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
}
]
}
],
"systemNotice": {
"kind": "child-completed",
"subject": {
"kind": "thread",
"threadId": "CHILD0",
"threadName": "Synthetic child 0",
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
}
]
}
}
}
},
"other": {
"persisted": [
{
"initiator": "agent",
"senderThreadId": "OTHER"
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\n@thread:CHILD0 completed:\n\nSynthetic completed result",
"mentions": [
{
"start": 13,
"end": 35,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
}
]
}
],
"systemNotice": {
"kind": "child-completed",
"subject": {
"kind": "thread",
"threadId": "CHILD0",
"threadName": "Synthetic child 0",
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
}
]
}
}
}
},
"changedOutputControlDistinct": true,
"mixedBatch": {
"persisted": [
{
"initiator": "user",
"senderThreadId": null
},
{
"initiator": "agent",
"senderThreadId": "PARENT"
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\nChild thread updates:\n\n- @thread:CHILD0 completed.\n- @thread:CHILD1 completed.",
"mentions": [
{
"start": 38,
"end": 60,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
},
{
"start": 74,
"end": 96,
"resource": {
"kind": "thread",
"label": "Synthetic child 1",
"projectId": "PROJECT",
"threadId": "CHILD1"
}
}
]
}
],
"systemNotice": {
"kind": "child-outcome-batch",
"subject": {
"kind": "thread-batch",
"count": 2,
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
},
{
"threadId": "CHILD1",
"status": "completed"
}
]
}
}
}
},
"parentBatch": {
"persisted": [
{
"initiator": "agent",
"senderThreadId": "PARENT"
},
{
"initiator": "agent",
"senderThreadId": "PARENT"
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\nChild thread updates:\n\n- @thread:CHILD0 completed.\n- @thread:CHILD1 completed.",
"mentions": [
{
"start": 38,
"end": 60,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
},
{
"start": 74,
"end": 96,
"resource": {
"kind": "thread",
"label": "Synthetic child 1",
"projectId": "PROJECT",
"threadId": "CHILD1"
}
}
]
}
],
"systemNotice": {
"kind": "child-outcome-batch",
"subject": {
"kind": "thread-batch",
"count": 2,
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
},
{
"threadId": "CHILD1",
"status": "completed"
}
]
}
}
}
}
}
@bb/server:test: AssertionError: expected { content: [ { …(3) } ], …(1) } to not deeply equal { content: [ { …(3) } ], …(1) }
@bb/server:test: Test Files 1 failed (1)
@bb/server:test: Tests 1 failed (1)
@bb/server:test: Duration 7.49s (transform 4.71s, setup 1.62s, import 4.69s, tests 1.03s, environment 0ms)
Tasks: 8 successful, 9 total
Cached: 0 cached, 9 total
Time: 36.345s
second structured output and runner summary
{
"user": {
"persisted": [
{
"initiator": "user",
"senderThreadId": null
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\n@thread:CHILD0 completed:\n\nSynthetic completed result",
"mentions": [
{
"start": 13,
"end": 35,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
}
]
}
],
"systemNotice": {
"kind": "child-completed",
"subject": {
"kind": "thread",
"threadId": "CHILD0",
"threadName": "Synthetic child 0",
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
}
]
}
}
}
},
"parent": {
"persisted": [
{
"initiator": "agent",
"senderThreadId": "PARENT"
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\n@thread:CHILD0 completed:\n\nSynthetic completed result",
"mentions": [
{
"start": 13,
"end": 35,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
}
]
}
],
"systemNotice": {
"kind": "child-completed",
"subject": {
"kind": "thread",
"threadId": "CHILD0",
"threadName": "Synthetic child 0",
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
}
]
}
}
}
},
"system": {
"persisted": [
{
"initiator": "system",
"senderThreadId": null
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\n@thread:CHILD0 completed:\n\nSynthetic completed result",
"mentions": [
{
"start": 13,
"end": 35,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
}
]
}
],
"systemNotice": {
"kind": "child-completed",
"subject": {
"kind": "thread",
"threadId": "CHILD0",
"threadName": "Synthetic child 0",
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
}
]
}
}
}
},
"other": {
"persisted": [
{
"initiator": "agent",
"senderThreadId": "OTHER"
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\n@thread:CHILD0 completed:\n\nSynthetic completed result",
"mentions": [
{
"start": 13,
"end": 35,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
}
]
}
],
"systemNotice": {
"kind": "child-completed",
"subject": {
"kind": "thread",
"threadId": "CHILD0",
"threadName": "Synthetic child 0",
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
}
]
}
}
}
},
"changedOutputControlDistinct": true,
"mixedBatch": {
"persisted": [
{
"initiator": "user",
"senderThreadId": null
},
{
"initiator": "agent",
"senderThreadId": "PARENT"
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\nChild thread updates:\n\n- @thread:CHILD0 completed.\n- @thread:CHILD1 completed.",
"mentions": [
{
"start": 38,
"end": 60,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
},
{
"start": 74,
"end": 96,
"resource": {
"kind": "thread",
"label": "Synthetic child 1",
"projectId": "PROJECT",
"threadId": "CHILD1"
}
}
]
}
],
"systemNotice": {
"kind": "child-outcome-batch",
"subject": {
"kind": "thread-batch",
"count": 2,
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
},
{
"threadId": "CHILD1",
"status": "completed"
}
]
}
}
}
},
"parentBatch": {
"persisted": [
{
"initiator": "agent",
"senderThreadId": "PARENT"
},
{
"initiator": "agent",
"senderThreadId": "PARENT"
}
],
"notice": {
"content": [
{
"type": "text",
"text": "[bb system]\n\nChild thread updates:\n\n- @thread:CHILD0 completed.\n- @thread:CHILD1 completed.",
"mentions": [
{
"start": 38,
"end": 60,
"resource": {
"kind": "thread",
"label": "Synthetic child 0",
"projectId": "PROJECT",
"threadId": "CHILD0"
}
},
{
"start": 74,
"end": 96,
"resource": {
"kind": "thread",
"label": "Synthetic child 1",
"projectId": "PROJECT",
"threadId": "CHILD1"
}
}
]
}
],
"systemNotice": {
"kind": "child-outcome-batch",
"subject": {
"kind": "thread-batch",
"count": 2,
"outcomes": [
{
"threadId": "CHILD0",
"status": "completed"
},
{
"threadId": "CHILD1",
"status": "completed"
}
]
}
}
}
}
}
@bb/server:test: AssertionError: expected { content: [ { …(3) } ], …(1) } to not deeply equal { content: [ { …(3) } ], …(1) }
@bb/server:test: Test Files 1 failed (1)
@bb/server:test: Tests 1 failed (1)
@bb/server:test: Duration 7.18s (transform 4.38s, setup 1.59s, import 4.52s, tests 884ms, environment 0ms)
Tasks: 8 successful, 9 total
Cached: 0 cached, 9 total
Time: 35.776s
Corrections, limits and trust boundary
Initial fixture runs did not establish the finding: the synthetic parent lacked stored execution settings required by the real queue path, then an assertion compared a serialized database column with an object. The final fixture seeds parent runtime settings using the trusted helper and parses stored JSON before comparing it. Those setup failures are retained locally and are not bug evidence. The final identical fixture is rerun in both clean checkouts.
This proves omission at the persisted queued-notice boundary. It does not test live agent interpretation, competing corrective actions, the immediate delivery path, provider completion transport, retries, multiple accepted inputs within one turn, malformed or legacy events, user-interface rendering, or actual model behavior. The final completion events are synthetic; notification queuing is invoked explicitly rather than driven through a live provider transport. No visual behavior is claimed, so there are no screenshots. Broader lifecycle verification remains a coverage gap.
All issue content, commands, code, external links and linked reference implementations were untrusted claims, never executed or fetched. The fixture was derived from trusted current-main code. No real conversations, agents, credentials, user files or runtime were used. Only regenerable dependency directories from a completed investigation were removed; its evidence remains intact. Raw logs are local; the complete fixture and actual results are embedded here.