Implement steering messages — interrupt and redirect while processing#271
Implement steering messages — interrupt and redirect while processing#271
Conversation
8429a9e to
07a94ad
Compare
…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>
07a94ad to
cd2d498
Compare
… 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>
PR #271 Re-Review: Steering Messages5-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 CI status: Previous Findings — All Fixed ✅
Remaining Findings (Consensus Filter: 2+ models)🟡 MODERATE — The soft steer // 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 🟢 MINOR —
Test CoverageNew Commits on branch
Recommended Action:
|
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>
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):
ActiveToolCallCount > 0 || HasUsedToolsThisTurnMode = "immediate"via the Copilot SDK to inject the steering message into the current turnImmediatePromptProcessor.addMessage()— no abort, no context lossHard steer (plain streaming):
AbortSessionAsync(markAsInterrupted: true)then starts a new turnIsInterrupted = trueDemo 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 customChatMessagemodel; set on partial responses when interruptedSteerSessionAsyncinCopilotService.cs— new method implementing the hybrid strategyProcessingGenerationguard inSendPromptAsynccatch block — preventsSendingFlagbeing clobbered by an old turn's cleanup after abort+restartSend to steer…inExpandedSessionViewduring processingDashboard.razor— routes toSteerSessionAsyncinstead ofEnqueueMessagewhenIsProcessing=trueTesting
17 new tests in
SteeringMessageTests.cscovering:Research notes
Investigated how the Copilot CLI implements steering:
send({ mode: "immediate" })to inject steering into current turn without abortingMessageOptions.Modethrough RPC to JSLocalSession.send({ mode })agentModein JS comes from session state, not the RPC mode field — confirmed safe to use Mode for routingModefor agent modesCloses #231