feat(plugin): trace follow-ups that join a running turn

Merged
AL
3mo ago
+53-5

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 of startTurnSpan that parents to a span we already hold instead of ROOT_CONTEXT. It deliberately sets no lmnr.span.parent_path / parent_ids_path; the processor derives lmnr.span.path from the parent's recorded path, which is present because the parent is still open.
  • plugin.ts: split the isSubagent || turnSpanAlreadyOpen guard. Sub-agents still return early. A message arriving on a session with a live turn now emits a zero-duration injected_input child 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_input child 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 startChildSpan and update the plugin guard so only sub-agents return early; normal sessions record the injection while keeping turn.input unchanged.
  • Bug Fixes

    • Record the message ID using output.message.id (not input.messageID) so IDs are always present; applied to both turn spans and injected_input.