Skip to content

Implement steering messages — interrupt and redirect while processing#271

Merged
PureWeen merged 8 commits intomainfrom
steering-hybrid
Mar 5, 2026
Merged

Implement steering messages — interrupt and redirect while processing#271
PureWeen merged 8 commits intomainfrom
steering-hybrid

Conversation

@PureWeen
Copy link
Copy Markdown
Owner

@PureWeen PureWeen commented Mar 4, 2026

What

When a user sends a message while the assistant is still processing, PolyPilot now supports steering messages — the ability to interrupt and redirect the conversation in a new direction.

How

Hybrid steering strategy

Two steering modes based on session state:

Soft steer (agentic/tool-heavy sessions):

  • Triggered when ActiveToolCallCount > 0 || HasUsedToolsThisTurn
  • Uses Mode = "immediate" via the Copilot SDK to inject the steering message into the current turn
  • Maps to the CLI's ImmediatePromptProcessor.addMessage() — no abort, no context loss
  • Preserves tool call history and conversation context

Hard steer (plain streaming):

  • Triggered when no tools are/were active
  • Calls AbortSessionAsync(markAsInterrupted: true) then starts a new turn
  • Immediate interruption; partial response marked with IsInterrupted = true

Demo and Remote sessions skip soft steer (no real CopilotSession) and fall through to hard steer.

Key changes

  • ChatMessage.IsInterrupted — new bool property on PolyPilot's custom ChatMessage model; set on partial responses when interrupted
  • SteerSessionAsync in CopilotService.cs — new method implementing the hybrid strategy
  • ProcessingGeneration guard in SendPromptAsync catch block — prevents SendingFlag being clobbered by an old turn's cleanup after abort+restart
  • UI placeholder — shows Send to steer… in ExpandedSessionView during processing
  • Dashboard.razor — routes to SteerSessionAsync instead of EnqueueMessage when IsProcessing=true

Testing

17 new tests in SteeringMessageTests.cs covering:

  • Hard steer path (plain streaming)
  • Soft steer path (tools active, tools used this turn)
  • Demo mode bypass
  • Generation guard (no double SendingFlag reset)
  • IsInterrupted flag set on partial responses

Research notes

Investigated how the Copilot CLI implements steering:

  • CLI uses send({ mode: "immediate" }) to inject steering into current turn without aborting
  • .NET SDK wires MessageOptions.Mode through RPC to JS LocalSession.send({ mode })
  • agentMode in JS comes from session state, not the RPC mode field — confirmed safe to use Mode for routing
  • PR Fix agent mode routing -- stop overloading MessageOptions.Mode #232 (merged) cleaned up the prior misuse of Mode for agent modes

Closes #231

PureWeen and others added 6 commits March 5, 2026 08:20
…eer conversation

When a user sends a message while the assistant is still processing (IsProcessing=true),
the new message now aborts the current turn and starts a fresh one with the steering
message, rather than queuing it.

Changes:
- ChatMessage: add IsInterrupted property to mark partial responses cut off by steering
- CopilotService.AbortSessionAsync: add markAsInterrupted param, set IsInterrupted on
  flushed partial response, explicitly reset SendingFlag so SteerSessionAsync can
  immediately acquire it for the new send
- CopilotService.SteerSessionAsync: new public method — calls AbortSessionAsync with
  markAsInterrupted=true then fires SendPromptAsync for the steering message
- Dashboard.razor: replace EnqueueMessage with SteerSessionAsync when IsProcessing=true
  in SendFromCard; uses STEER dispatch route log tag
- ExpandedSessionView.razor: update textarea placeholder from 'Message will be queued…'
  to 'Send to steer…' during processing
- SteeringMessageTests.cs: 13 new tests covering IsInterrupted model property, abort
  with markAsInterrupted, and SteerSessionAsync behavior in demo mode

All 1413 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eerSessionAsync send

Issue 1 — Race: old turn's catch block clobbering new turn's SendingFlag
  - Before try{}, declare long myGeneration = 0
  - After Interlocked.Increment(ref ProcessingGeneration), capture:
    myGeneration = Interlocked.Read(ref state.ProcessingGeneration)
  - In catch block, only release SendingFlag if the generation hasn't advanced:
    if (Interlocked.Read(ref state.ProcessingGeneration) == myGeneration)
  This ensures that when AbortSessionAsync resets SendingFlag=0 and SteerSessionAsync
  starts a new turn (incrementing generation), the old turn's TaskCanceledException
  catch path sees a mismatched generation and does NOT clobber the new turn's lock.

Issue 2 — SteerSessionAsync was fire-and-forget; failures were silently swallowed
  - Replace _ = SendPromptAsync(...).ContinueWith(...) with �wait SendPromptAsync(...)
  - SteerSessionAsync now returns a Task that reflects full outcome; errors propagate
  - Dashboard.razor already uses .ContinueWith on the SteerSessionAsync call, so
    callers handle async errors correctly

Tests: add SteerSession_AfterAbort_NewTurnCanSend and SteerSession_IsAwaitable_ExceptionPropagates
All 1415 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When SteerSessionAsync is called during tool-heavy agentic sessions
(ActiveToolCallCount > 0 || HasUsedToolsThisTurn), use soft steer:
  - Send with Mode='immediate' to inject into current turn
  - Preserves tool context and conversation history

When no tools are active (plain streaming), fall back to hard steer:
  - Abort current turn (marking partial response as interrupted)
  - Start new turn with the steering message

Demo and Remote sessions skip soft steer (no real CopilotSession).

Fix Interlocked.Read -> Volatile.Read for int ActiveToolCallCount.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix 1 (CRITICAL): Soft steer drops user message from history
- Add ChatMessage to History, MessageCount, LastReadMessageCount, and
  DB write-through before calling state.Session.SendAsync in soft steer path
- Matches the same pattern as the hard steer path (SendPromptAsync)

Fix 2 (SHOULD FIX): Non-atomic Increment + Read in generation guard
- Capture myGeneration = Interlocked.Increment(...) directly instead of
  a separate Interlocked.Read that races with abort/new-turn on another thread

Fix 3 (SHOULD FIX): HasUsedToolsThisTurn plain read in steer decision
- Use Volatile.Read(ref state.HasUsedToolsThisTurn) to match the
  Volatile.Write already used on the write side (INV-7 compliance)

Tests: add SteerSession_HardSteer_AddsUserMessageToHistoryAndDb and
SteerSession_SoftSteerPath_ContainsUserMessageAddition (19 total, all pass)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…session

If SendAsync throws (network error, SDK crash), the session was left with
IsProcessing=true and no recovery path other than the 600s watchdog timeout.

Add a try/catch that mirrors the existing SendAsync error path in
SendPromptAsync: clears all 9 companion fields, calls FlushCurrentResponse,
logs [STEER-ERROR], fires OnError, and invokes OnStateChanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…catch block

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… soft steer error path

AbortSessionAsync clears MessageQueue, _queuedImagePaths, _queuedAgentModes,
FlushedResponse, and PendingReasoningMessages on abort. The soft steer catch
block (SteerSessionAsync) was missing all five of these, which meant stale
queued messages could auto-send after the next successful turn completes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Copy Markdown
Owner Author

PureWeen commented Mar 5, 2026

PR #271 Re-Review: Steering Messages

5-model consensus review (claude-opus-4.6 ×2, claude-sonnet-4.6, gemini-3-pro-preview, gpt-5.3-codex) of the full diff including the prior fix commit cd2d498.

CI status: ⚠️ No checks reported on steering-hybrid branch (pre-existing: 13 PopupThemeTests failures unrelated to this PR).


Previous Findings — All Fixed ✅

Finding Status
P1: SendingFlag not released in soft steer catch → session deadlock ✅ FIXED (Interlocked.Exchange added)
P2: ResponseCompletion TCS orphaned in soft steer catch → task leak ✅ FIXED (TrySetCanceled() added)
P3: SteerSessionAsync no IsProcessing guard ✅ FIXED (check present)

Remaining Findings (Consensus Filter: 2+ models)

🟡 MODERATE — CopilotService.cs:2333 — Soft steer error path missing queue cleanup (2/4 models)

The soft steer catch block cleared IsProcessing, SendingFlag, and companion fields — but was missing the same queue cleanup that AbortSessionAsync performs:

// Missing from soft steer catch (AbortSessionAsync does all of these):
state.Info.MessageQueue.Clear();
_queuedImagePaths.TryRemove(sessionName, out _);
_queuedAgentModes.TryRemove(sessionName, out _);
state.FlushedResponse.Clear();
state.PendingReasoningMessages.Clear();

Stale queue entries would persist and auto-send after the next successful turn completes. Fixed in commit 542fda3.

🟢 MINOR — CopilotService.cs:1951myGeneration = 0 is a silent-failure sentinel (3/4 models)

myGeneration is initialized to 0 outside the try block, then set to the return of Interlocked.Increment as the 5th statement inside try. The four statements before the increment are simple property assignments that cannot throw. However, if the initialization order ever changes and an early throw occurs before the increment, myGeneration stays 0, the generation guard 0 != actual_generation fails, and SendingFlag is never released — permanent session deadlock. Recommend moving the increment to be the very first statement inside the try block (or use long.MinValue as sentinel), to make the guard fail-loud instead of fail-silent.


Test Coverage

New SteeringMessageTests.cs (353 lines, 19 tests) covers: IsInterrupted model property, abort with/without mark, SteerSessionAsync idle/processing/tool paths, fire-and-forget safety, history persistence, and lock-guard regression. Coverage is thorough for the hard steer path. The soft steer path is (correctly) not unit-testable without a live SDK session — the SteerSession_SoftSteerPath_ContainsUserMessageAddition test compensates by validating source structure via reflection.


Commits on branch

  • 831215c feat: implement steering messages
  • 6ea1bd0 fix: guard SendingFlag release by generation in catch block
  • 10e1d6e fix: address race conditions and missing history
  • cd03d76 feat: hybrid steering — soft steer via Mode=immediate when tools active
  • cfe1043 fix: add try/catch around soft steer SendAsync
  • cd2d498 fix: release SendingFlag and signal ResponseCompletion in soft steer catch (prior fix)
  • 542fda3 fix: clear MessageQueue, FlushedResponse, PendingReasoningMessages in soft steer error path (this review)

Recommended Action: ⚠️ Request Changes

One item outstanding: move myGeneration = Interlocked.Increment(...) to be the first statement inside the try block (minor refactor, 5-line change) to eliminate the silent-deadlock risk if initialization order changes. All critical findings are now resolved.

Prevents permanent session lock if an exception occurs between
CompareExchange(SendingFlag) and the generation assignment. With
myGeneration=0 and ProcessingGeneration>0 from a prior turn, the
catch guard would skip releasing SendingFlag.

Also resolves merge conflict in Dashboard.razor: split the
IsCreating+IsProcessing block into two separate guards so that
IsCreating sessions queue (unchanged from main) while IsProcessing
sessions steer (from this PR).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen merged commit c5e5022 into main Mar 5, 2026
@PureWeen PureWeen deleted the steering-hybrid branch March 5, 2026 20:41
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