feat(plugin): trace follow-ups that join a running turn
Issue for this PR
Closes #
Type of change
- New feature
What does this PR do?
A follow-up sent while the agent is working is now absorbed by the run already in flight rather than cancelling it — the agent loop re-reads the session's history at the top of every step, so the message joins the turn already in progress. Laminar had no record of that happening.
Two reasons, one line: chat.message returns early when a turn span is already open (plugin.ts), and the turn span's lmnr.span.input is serialized once at creation so it cannot grow afterwards. The only evidence that a follow-up ever arrived was the LLM call's message array quietly getting longer from one step to the next.
Here is a real staging run. The follow-up ("skip the file for the letter O") landed at ~18:00:46.8, between step 3 and step 4, and appears nowhere:
v4.run 0.8s
└── turn 43.9s input = {message: "Make 26 files..."}
├── session.llm #1 6.5s user_msgs=1
├── session.llm #2 9.0s user_msgs=1
├── session.llm #3 18.0s user_msgs=1 (apply_patch ends 18:00:46.794)
│ · · · · · · · · · · · INJECTION HERE — INVISIBLE
├── session.llm #4 3.7s user_msgs=2 <- first call that can see it
├── session.llm #5 2.6s user_msgs=2
└── session.llm #6 3.2s user_msgs=2
After this change:
v4.run
└── turn input = opening message (unchanged)
├── session.llm #1
├── session.llm #2
├── session.llm #3
├── injected_input <- NEW: input = { messageID, message, parts }
├── session.llm #4
├── session.llm #5
└── session.llm #6
Changes:
span.ts:startChildSpan— a sibling ofstartTurnSpanthat parents to a span we already hold instead ofROOT_CONTEXT. It deliberately sets nolmnr.span.parent_path/parent_ids_path; the processor deriveslmnr.span.pathfrom the parent's recorded path, which is present because the parent is still open.plugin.ts: split theisSubagent || turnSpanAlreadyOpenguard. Sub-agents still return early. A message arriving on a session with a live turn now emits a zero-durationinjected_inputchild instead of returning silently.VENDOR.md: recorded under "Behavior additions (vs upstream)" so a future re-pull does not drop it.
Why it nests correctly: Laminar's UI nests by the lmnr.span.path attribute, not by OTel parentSpanId. processor.ts computes that path in onStart from spanIdToPath.get(parentSpanId), so a hand-parented child inherits [v4.run, turn] and becomes [v4.run, turn, injected_input]. The AI-SDK re-parenting branch does not interfere — it only fires for spans that carry ai.telemetry.metadata.sessionId and have no parent, and this span is the other way round on both counts.
The turn span's input is left alone. Rewriting a serialized JSON attribute in place to append a second message would be fragile, and "the message that opened this turn" is a well-defined thing for it to hold. The full input set for a turn is now turn.input plus its injected_input children.
This fires for interactive TUI follow-ups too, not just the v4 cloud path — same hook, same semantics.
How did you verify your code works?
bun typecheck (16/16) and bun run lint on packages/bcode-laminar/src — 16 warnings / 0 errors, byte-identical to the count on main, so no new lint findings.
There is no test harness or CI test job in this package, so rather than commit a test nothing runs, I verified both halves against the real vendored processor with an InMemorySpanExporter and against the real plugin hook, then deleted the scripts. Span mechanics, using the v4 parent-context shape:
PASS exported both spans
PASS path is v4.run>turn>injected_input
PASS ids_path has 3 levels
PASS otel parent is the turn span
PASS same trace as turn
PASS span type DEFAULT
PASS session id stamped
PASS input carries the injected text
PASS turn input unchanged
Hook behavior, calling chat.message twice on one session plus once for a sub-agent:
PASS first message opens a turn
PASS turn is still recorded after 2nd
PASS turn span was NOT replaced
PASS exactly one session tracked
PASS sub-agent gets no span
Not yet observed end-to-end in Laminar: the v4 sandbox image pins bcode 0.1.17, so this needs a release and a Dockerfile bump in cloud before it shows up on staging traces.
Screenshots / recordings
n/a — trace structure, diagrammed above.
Checklist
- I have tested my changes locally
- I have not included unrelated changes in this PR
Summary by cubic
Record mid-run follow-ups by emitting a zero-duration injected_input span under the active turn. Also capture the resolved message ID from output.message.id on both turn and injected spans for reliable correlation.
-
New Features
- Emit an
injected_inputchild span when a user message arrives during an open turn; it includes sessionId, messageId, message, and parts, and appears between the steps it landed between. - Add
startChildSpanand update the plugin guard so only sub-agents return early; normal sessions record the injection while keepingturn.inputunchanged.
- Emit an
-
Bug Fixes
- Record the message ID using
output.message.id(notinput.messageID) so IDs are always present; applied to both turn spans andinjected_input.
- Record the message ID using