Skip to content

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

Merged
Alezander9 merged 2 commits into
mainfrom
alex/laminar-injected-input
Aug 4, 2026
Merged

feat(plugin): trace follow-ups that join a running turn#146
Alezander9 merged 2 commits into
mainfrom
alex/laminar-injected-input

Conversation

@Alezander9

@Alezander9 Alezander9 commented Aug 4, 2026

Copy link
Copy Markdown
Member

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.

Written for commit f7c7348. Summary will update on new commits.

Review in cubic

A user message sent while the agent is working is absorbed by the run
already in flight — the agent loop re-reads the session's history at the
top of every step, so the message joins the current turn instead of
starting a new one. Laminar had no record of it: `chat.message` returns
early when a turn span is already open, and the turn span's `input` is
serialized once at creation and cannot grow. The only evidence a
follow-up ever arrived was the LLM call's message array getting longer
between one step and the next.

Mark it with a zero-duration `injected_input` span nested in the live
turn, carrying the message parts. It lands between the steps it arrived
between, so the trace shows where in the conversation the follow-up was
picked up, and injections become queryable by span name.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread packages/bcode-laminar/src/plugin.ts
`input.messageID` is the caller's optional override. Every v4 run omits it
— the worker POSTs /prompt_async with just parts and model — so opencode
generates the id at prompt.ts:657 and the hook's `messageID` is undefined,
which JSON.stringify then drops from the span input entirely.

Read `output.message.id` instead: the id the message was actually
persisted under, present whether the caller supplied one or not. Applies
to the turn span too, which had the same gap.
@Alezander9
Alezander9 merged commit aaf57d4 into main Aug 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant