Skip to content

fix(logging): wrap sidecar IChatClient calls in SessionDiagnosticsContext (#920)#926

Merged
Aaronontheweb merged 3 commits into
netclaw-dev:devfrom
Aaronontheweb:fix/sidecar-diagnostics-context
May 8, 2026
Merged

fix(logging): wrap sidecar IChatClient calls in SessionDiagnosticsContext (#920)#926
Aaronontheweb merged 3 commits into
netclaw-dev:devfrom
Aaronontheweb:fix/sidecar-diagnostics-context

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Collaborator

Summary

Closes #920. Wraps every session-owned sidecar IChatClient.{Get,GetStreaming}ResponseAsync call in SessionDiagnosticsContext.Push(sessionId.Value) so MEL provider diagnostics emitted during the call route into the per-session session.log.

Before this PR, only the main turn path (SessionLlmInvoker.InvokeAsync) populated the diagnostics scope. Sidecar paths — compaction, title generation, sub-agent orchestration, memory extraction, memory observer distillation, and memory curation — emitted to the daemon log without a session tag and never reached session.log. That gap was the limitation called out in #920 and the rationale for the planned Roslyn analyzer in #915.

Sites covered

Path Method Notes
Compaction SessionCompactionPipeline.GenerateObservationsAsync one-line scope wrap
Title generation SessionTitleGenerator.GenerateAsync +SessionId parameter
Sub-agent SubAgentActor.InvokeLlmAsync +SessionId? sessionId; bumped to internal static; null-sessionId case for sub-agents that run outside any session
Main-session memory extraction LlmSessionActor.InvokeMemoryExtractionCoreAsync +SessionId parameter; bumped to internal static
Memory observer distillation SessionMemoryObserverActor.RunDistillationAsync extracted from private instance to internal static (passes _log as param) — matches the pattern of the other sidecar helpers
Memory curation MemoryCurationActor.TryLlmEvaluationAsync same — extracted from private instance to internal static; constructor +SessionId parameter

The two static extractions (RunDistillationAsync, TryLlmEvaluationAsync) are behavior-preserving refactors that bring those helpers into the same shape as the existing SessionTitleGenerator/SessionCompactionPipeline. They make the methods unit-testable in isolation without spinning up the parent actor or driving the full message protocol.

Tests

SidecarDiagnosticsContextTests covers every path listed above. A SessionContextCapturingChatClient records SessionDiagnosticsContext.SessionId at the moment the chat client method is invoked — that AsyncLocal value is exactly what any real provider plugin would see when emitting MEL log lines inside the call.

7 tests, all green:

  • TitleGenerator_populates_session_diagnostics_scope
  • CompactionPipeline_populates_session_diagnostics_scope
  • MemoryExtraction_populates_session_diagnostics_scope
  • SubAgent_InvokeLlm_populates_session_diagnostics_scope
  • SubAgent_InvokeLlm_with_null_sessionId_pushes_null_scope
  • MemoryObserver_RunDistillation_populates_session_diagnostics_scope
  • MemoryCuration_TryLlmEvaluation_populates_session_diagnostics_scope

This PR's coverage is independent of #915 (the analyzer): the contract is now verified at the unit-test level for every call site whether or not the analyzer ever lands.

Quality gates

  • 1482 Actors tests (was 1477; +5 new) + 504 Daemon tests, all pass.
  • Slopwatch clean.
  • Copyright headers verified.

References

…text

Closes the gap tracked in netclaw-dev#920: session-owned sidecar paths bypassed
the SessionDiagnosticsContext scope, so MEL provider diagnostics
emitted during their LLM calls landed in the daemon log without a
session-id tag and never reached session.log.

Wrapped every direct IChatClient.{Get,GetStreaming}ResponseAsync call
in session-owned code with a using-scope of
SessionDiagnosticsContext.Push(sessionId.Value):

- SessionCompactionPipeline.GenerateObservationsAsync — sessionId
  already on the signature; one-line wrap.
- SessionMemoryObserverActor.RunDistillationAsync — sessionId already
  on the signature; one-line wrap.
- SessionTitleGenerator.GenerateAsync — added SessionId parameter;
  caller in LlmSessionActor passes _sessionId.
- LlmSessionActor.InvokeMemoryExtractionCoreAsync — added SessionId
  parameter; caller threads _sessionId.
- SubAgentActor.InvokeLlmAsync — added string? sessionId parameter;
  passes _toolExecutionContext.SessionId. Sub-agents share the parent's
  session.log (SessionDiagnosticsContext.NormalizeSessionId strips the
  /subagent/ suffix back to the parent id).
- MemoryCurationActor — added SessionId field + constructor param;
  CreateProps signature changed; LlmSessionActor caller updated;
  storage-integration tests updated to pass a placeholder sessionId.

Test: SidecarDiagnosticsContextTests verifies the contract for
SessionTitleGenerator and SessionCompactionPipeline (the two
pure-static helpers, easiest to drive). A capturing fake IChatClient
records SessionDiagnosticsContext.SessionId at the moment its method
is invoked — that AsyncLocal value is what any real provider plugin
emitting MEL log lines inside the call would see.

Sidecar paths that did not need changes:
- SessionLlmInvoker.InvokeAsync already pushes the scope (the original
  PR netclaw-dev#916 wired that path).

Net diagnostic coverage now matches what the Roslyn analyzer planned
in netclaw-dev#915 will require.
- MemoryCurationActor: replace Protocol.SessionId qualifier with a
  using alias matching the SessionLlmInvoker.cs:9 pattern. A full
  Netclaw.Actors.Protocol namespace import would have introduced a
  ChatRole ambiguity (Protocol.ChatRole vs Microsoft.Extensions.AI.ChatRole)
  that the bare alias avoids cleanly.
- SidecarDiagnosticsContextTests: drop two defensive
  SessionDiagnosticsContext.SessionId = null pre-test resets. xUnit
  creates a fresh class instance per test method and AsyncLocal is
  rooted in the per-task ExecutionContext; there's no shared state to
  reset. The Assert.Null at the end of each test still verifies the
  using-scope restored properly.
- SubAgentActor: tighten the diagnostics-scope comment from 4 lines
  to 3, dropping the redundant "shares parent's session.log" framing
  that's now covered by the SessionDiagnosticsContext doc.
Three follow-up fixes from the simplify-pass review on the sidecar
diagnostics-context PR. Each addresses something I had previously left
on the table:

1. Stringly-typed sessionId on SubAgentActor.InvokeLlmAsync replaced
   with `SessionId? sessionId`. The call site converts the upstream
   `_toolExecutionContext.SessionId` (which is still typed as string?
   in Netclaw.Tools.Abstractions; out of scope to retype here)
   into the value object once at the boundary.

2. Positional ordering on InvokeLlmAsync normalized: ct now last,
   sessionId after options. The previous "ct between options and self"
   placement was inherited from the original signature and was awkward
   for the new sessionId parameter; the conventional order makes the
   private API harder to mis-call from a future test.

3. Test coverage gap closed. All seven sidecar IChatClient call sites
   now have direct unit tests in SidecarDiagnosticsContextTests:
   - SessionTitleGenerator.GenerateAsync
   - SessionCompactionPipeline.GenerateObservationsAsync
   - LlmSessionActor.InvokeMemoryExtractionCoreAsync (now internal static)
   - SubAgentActor.InvokeLlmAsync (now internal static; null-sessionId
     case also covered)
   - SessionMemoryObserverActor.RunDistillationAsync (now internal
     static; was private instance — extracted to match the existing
     SessionTitleGenerator/SessionCompactionPipeline pattern, _log
     passed as ILoggingAdapter parameter)
   - MemoryCurationActor.TryLlmEvaluationAsync (now internal static;
     same shape — _llmClient, _sessionId, _log all passed as params)

The static extractions for RunDistillationAsync and TryLlmEvaluationAsync
are behavior-preserving refactors that follow the established pattern
of the other sidecar helpers in the same namespace. They make these
methods unit-testable in isolation without spinning up the parent
actor or driving the message protocol.

This closes the coverage gap I previously deferred to the planned
Roslyn analyzer (netclaw-dev#915). Now we don't depend on netclaw-dev#915 landing — the
contract is verified at the unit-test level for every call site.
@Aaronontheweb
Aaronontheweb merged commit cfd5778 into netclaw-dev:dev May 8, 2026
6 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.

session log: wrap remaining sidecar IChatClient call sites in SessionDiagnosticsContext.Push

1 participant