feat(plugin): trace follow-ups that join a running turn - #146
Merged
Conversation
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.
There was a problem hiding this comment.
All reported issues were addressed across 3 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue for this PR
Closes #
Type of change
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.messagereturns early when a turn span is already open (plugin.ts), and the turn span'slmnr.span.inputis 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:
After this change:
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.pathattribute, not by OTelparentSpanId.processor.tscomputes that path inonStartfromspanIdToPath.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 carryai.telemetry.metadata.sessionIdand have no parent, and this span is the other way round on both counts.The turn span's
inputis 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 nowturn.inputplus itsinjected_inputchildren.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) andbun run lintonpackages/bcode-laminar/src— 16 warnings / 0 errors, byte-identical to the count onmain, 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
InMemorySpanExporterand against the real plugin hook, then deleted the scripts. Span mechanics, using the v4 parent-context shape:Hook behavior, calling
chat.messagetwice on one session plus once for a sub-agent: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
Summary by cubic
Record mid-run follow-ups by emitting a zero-duration
injected_inputspan under the active turn. Also capture the resolved message ID fromoutput.message.idon both turn and injected spans for reliable correlation.New Features
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.startChildSpanand update the plugin guard so only sub-agents return early; normal sessions record the injection while keepingturn.inputunchanged.Bug Fixes
output.message.id(notinput.messageID) so IDs are always present; applied to both turn spans andinjected_input.Written for commit f7c7348. Summary will update on new commits.