Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ When switching between Embedded and Persistent modes (via Settings → Save & Re
### Platform Differences
`Models/PlatformHelper.cs` exposes `IsDesktop`/`IsMobile` and controls which `ConnectionMode`s are available. Mobile can only use Remote mode. Desktop defaults to Persistent.

**Mobile-only behavior:**
- Desktop menu items (Fix with Copilot, Copilot Console, Terminal, VS Code) are hidden via `PlatformHelper.IsDesktop` guards in `SessionListItem.razor`.
- Report Bug opens the browser with a pre-filled GitHub issue URL via `Launcher.Default.OpenAsync` instead of the inline sidebar form.
- Processing status indicator shows elapsed time and tool round count, synced via bridge.

## Critical Conventions

### Git Workflow
Expand Down Expand Up @@ -131,12 +136,35 @@ Disabled in `Platforms/MacCatalyst/Entitlements.plist` — required for spawning

### SDK Event Flow
When a prompt is sent, the SDK emits events processed by `HandleSessionEvent` in order:
1. `AssistantTurnStartEvent` → "Thinking..." indicator
2. `AssistantMessageDeltaEvent` → streaming content chunks
3. `AssistantMessageEvent` → full message (may include tool requests)
4. `ToolExecutionStartEvent` / `ToolExecutionCompleteEvent` → tool activity
5. `AssistantIntentEvent` → intent/plan updates
6. `SessionIdleEvent` → turn complete, response finalized
1. `SessionUsageInfoEvent` → server acknowledged, sets `ProcessingPhase=1`
2. `AssistantTurnStartEvent` → model generating, sets `ProcessingPhase=2`
3. `AssistantMessageDeltaEvent` → streaming content chunks
4. `AssistantMessageEvent` → full message (may include tool requests)
5. `ToolExecutionStartEvent` → tool activity starts, sets `ProcessingPhase=3`, increments `ToolCallCount` on complete
6. `ToolExecutionCompleteEvent` → tool done, increments `ToolCallCount`
7. `AssistantIntentEvent` → intent/plan updates
8. `AssistantTurnEndEvent` → end of a sub-turn, tool loop continues
9. `SessionIdleEvent` → turn complete, response finalized

### Processing Status Indicator
`AgentSessionInfo` tracks three fields for the processing status UI:
- `ProcessingStartedAt` (DateTime?) — set to `DateTime.UtcNow` in `SendPromptAsync`
- `ToolCallCount` (int) — incremented on each `ToolExecutionCompleteEvent`
- `ProcessingPhase` (int) — 0=Sending, 1=ServerConnected, 2=Thinking, 3=Working

All three are reset in `SendPromptAsync` (new turn) and cleared in `CompleteResponse` (turn done) and `AbortSessionAsync` (user stop). They're synced to mobile via `SessionSummary` in the bridge protocol.

The UI shows: "Sending…" → "Server connected…" → "Thinking…" → "Working · Xm Xs · N tool calls…".

### Abort Behavior
`AbortSessionAsync` must clear ALL processing state:
- `IsProcessing = false`, `IsResumed = false`
- `ProcessingStartedAt = null`, `ToolCallCount = 0`, `ProcessingPhase = 0`
- `MessageQueue.Clear()` — prevents queued messages from auto-sending after abort
- `_queuedImagePaths.TryRemove()` — clears associated image attachments
- `CancelProcessingWatchdog()` and `ResponseCompletion.TrySetCanceled()`

In remote mode, the mobile client optimistically clears all fields and delegates to the bridge server.

### Processing Watchdog
The processing watchdog (`RunProcessingWatchdogAsync` in `CopilotService.Events.cs`) detects stuck sessions by checking how long since the last SDK event. It checks every 15 seconds and has two timeout tiers:
Expand Down Expand Up @@ -231,7 +259,7 @@ Test files in `PolyPilot.Tests/`:
- `BridgeMessageTests.cs` — Bridge protocol serialization, type constants
- `RemoteModeTests.cs` — Remote mode payloads, organization state, chat serialization
- `ChatMessageTests.cs` — Chat message factory methods, state transitions
- `AgentSessionInfoTests.cs` — Session info properties, history, queue
- `AgentSessionInfoTests.cs` — Session info properties, history, queue, processing status fields
- `SessionOrganizationTests.cs` — Groups, sorting, metadata
- `ConnectionSettingsTests.cs` — Settings persistence
- `CopilotServiceInitializationTests.cs` — Initialization error handling, mode switching, fallback notices, CLI source persistence
Expand All @@ -241,7 +269,7 @@ Test files in `PolyPilot.Tests/`:
- `PlatformHelperTests.cs` — Platform detection
- `ToolResultFormattingTests.cs` — Tool output formatting
- `UiStatePersistenceTests.cs` — UI state save/load
- `ProcessingWatchdogTests.cs` — Watchdog constants, timeout selection, HasUsedToolsThisTurn, IsResumed
- `ProcessingWatchdogTests.cs` — Watchdog constants, timeout selection, HasUsedToolsThisTurn, IsResumed, abort clears queue and processing status
- `CliPathResolutionTests.cs` — CLI path resolution
- `InitializationModeTests.cs` — Mode initialization
- `PersistentModeTests.cs` — Persistent mode behavior
Expand Down
33 changes: 33 additions & 0 deletions PolyPilot.Tests/AgentSessionInfoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,39 @@ public void NewSession_HasEmptyHistoryAndQueue()
Assert.False(session.IsProcessing);
}

[Fact]
public void NewSession_HasDefaultProcessingStatusFields()
{
var session = new AgentSessionInfo { Name = "test", Model = "gpt-5" };

Assert.Null(session.ProcessingStartedAt);
Assert.Equal(0, session.ToolCallCount);
Assert.Equal(0, session.ProcessingPhase);
}

[Fact]
public void ProcessingStatusFields_CanBeSetAndCleared()
{
var session = new AgentSessionInfo { Name = "test", Model = "gpt-5" };

session.ProcessingStartedAt = DateTime.UtcNow;
session.ToolCallCount = 5;
session.ProcessingPhase = 3;

Assert.NotNull(session.ProcessingStartedAt);
Assert.Equal(5, session.ToolCallCount);
Assert.Equal(3, session.ProcessingPhase);

// Clear (as abort/complete would)
session.ProcessingStartedAt = null;
session.ToolCallCount = 0;
session.ProcessingPhase = 0;

Assert.Null(session.ProcessingStartedAt);
Assert.Equal(0, session.ToolCallCount);
Assert.Equal(0, session.ProcessingPhase);
}

[Fact]
public void History_CanAddMessages()
{
Expand Down
27 changes: 24 additions & 3 deletions PolyPilot.Tests/BridgeMessageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ public void Serialize_CamelCaseNaming()
Model = "gpt-5",
IsProcessing = true,
MessageCount = 5,
QueueCount = 2
QueueCount = 2,
ProcessingStartedAt = new DateTime(2025, 6, 1, 12, 0, 0, DateTimeKind.Utc),
ToolCallCount = 7,
ProcessingPhase = 3
};
var msg = BridgeMessage.Create(BridgeMessageTypes.SessionsList, payload);
var json = msg.Serialize();
Expand All @@ -97,6 +100,9 @@ public void Serialize_CamelCaseNaming()
Assert.Contains("\"model\"", json);
Assert.Contains("\"isProcessing\"", json);
Assert.Contains("\"messageCount\"", json);
Assert.Contains("\"processingStartedAt\"", json);
Assert.Contains("\"toolCallCount\"", json);
Assert.Contains("\"processingPhase\"", json);

// Verify null values are excluded (JsonIgnoreCondition.WhenWritingNull)
Assert.DoesNotContain("\"sessionId\"", json);
Expand Down Expand Up @@ -164,6 +170,7 @@ public class BridgePayloadTests
[Fact]
public void SessionsListPayload_RoundTrip()
{
var startedAt = new DateTime(2025, 6, 15, 10, 30, 0, DateTimeKind.Utc);
var payload = new SessionsListPayload
{
ActiveSession = "main",
Expand All @@ -177,7 +184,10 @@ public void SessionsListPayload_RoundTrip()
MessageCount = 10,
IsProcessing = false,
SessionId = "abc-123",
QueueCount = 0
QueueCount = 0,
ProcessingStartedAt = null,
ToolCallCount = 0,
ProcessingPhase = 0
},
new()
{
Expand All @@ -186,7 +196,10 @@ public void SessionsListPayload_RoundTrip()
CreatedAt = new DateTime(2025, 1, 1, 13, 0, 0, DateTimeKind.Utc),
MessageCount = 3,
IsProcessing = true,
QueueCount = 2
QueueCount = 2,
ProcessingStartedAt = startedAt,
ToolCallCount = 5,
ProcessingPhase = 3
}
}
};
Expand All @@ -202,6 +215,14 @@ public void SessionsListPayload_RoundTrip()
Assert.Equal("claude-opus-4.6", restoredPayload.Sessions[0].Model);
Assert.True(restoredPayload.Sessions[1].IsProcessing);
Assert.Equal(2, restoredPayload.Sessions[1].QueueCount);

// Verify processing status fields survive round-trip
Assert.Null(restoredPayload.Sessions[0].ProcessingStartedAt);
Assert.Equal(0, restoredPayload.Sessions[0].ToolCallCount);
Assert.Equal(0, restoredPayload.Sessions[0].ProcessingPhase);
Assert.Equal(startedAt, restoredPayload.Sessions[1].ProcessingStartedAt);
Assert.Equal(5, restoredPayload.Sessions[1].ToolCallCount);
Assert.Equal(3, restoredPayload.Sessions[1].ProcessingPhase);
}

[Fact]
Expand Down
27 changes: 27 additions & 0 deletions PolyPilot.Tests/ProcessingWatchdogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,33 @@ public async Task AbortSessionAsync_WorksRegardlessOfGeneration()
"AbortSessionAsync must always clear IsProcessing, regardless of generation");
}

[Fact]
public async Task AbortSessionAsync_ClearsQueueAndProcessingStatus()
{
// Abort must clear the message queue so queued messages don't auto-send,
// and reset processing status fields so the UI shows idle state.
var svc = CreateService();
await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo });

var session = await svc.CreateSessionAsync("abort-queue");

// Simulate active processing with queued messages
session.IsProcessing = true;
session.ProcessingStartedAt = DateTime.UtcNow;
session.ToolCallCount = 5;
session.ProcessingPhase = 3;
session.MessageQueue.Add("queued message 1");
session.MessageQueue.Add("queued message 2");

await svc.AbortSessionAsync("abort-queue");

Assert.False(session.IsProcessing);
Assert.Null(session.ProcessingStartedAt);
Assert.Equal(0, session.ToolCallCount);
Assert.Equal(0, session.ProcessingPhase);
Assert.Empty(session.MessageQueue);
}

[Fact]
public async Task AbortSessionAsync_AllowsSubsequentSend()
{
Expand Down
63 changes: 63 additions & 0 deletions PolyPilot.Tests/Scenarios/mode-switch-scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,69 @@
"note": "No sessions should be stuck in processing state after relaunch"
}
]
},
{
"id": "abort-clears-queue-and-status",
"name": "Stop button clears message queue and processing status",
"description": "When user clicks Stop on an active session, the message queue is cleared so no more messages auto-send, and processing status fields (elapsed time, tool rounds) are reset.",
"category": "session-management",
"unitTestCoverage": [
"ProcessingWatchdogTests.AbortSessionAsync_ClearsQueueAndProcessingStatus",
"ProcessingWatchdogTests.AbortSessionAsync_WorksRegardlessOfGeneration"
],
"steps": [
{
"action": "note",
"note": "A session must be actively processing (IsProcessing=true)"
},
{
"action": "evaluate",
"script": "document.querySelector('.send-btn.stop-btn') !== null",
"expect": { "equals": "true" },
"note": "Stop button should be visible for processing sessions"
},
{
"action": "click",
"target": ".send-btn.stop-btn",
"note": "Click the stop button"
},
{
"action": "evaluate",
"script": "!document.querySelector('.expanded-card.processing')",
"expect": { "equals": "true" },
"note": "Session card should no longer show processing state after stop"
}
]
},
{
"id": "processing-status-indicator",
"name": "Processing sessions show elapsed time and tool round count",
"description": "When a session is processing, the UI shows a status indicator with elapsed time and tool round count. Before the first SDK event, it shows 'Waiting for first response'. Processing status fields are synced via bridge to mobile.",
"category": "session-management",
"unitTestCoverage": [
"AgentSessionInfoTests.NewSession_HasDefaultProcessingStatusFields",
"AgentSessionInfoTests.ProcessingStatusFields_CanBeSetAndCleared",
"BridgeMessageTests.Serialize_CamelCaseNaming",
"BridgeMessageTests.SessionsListPayload_RoundTrip"
],
"steps": [
{
"action": "note",
"note": "A session must be actively processing"
},
{
"action": "evaluate",
"script": "document.querySelector('.expanded-card.processing') !== null",
"expect": { "equals": "true" },
"note": "Active session should show processing card state"
},
{
"action": "evaluate",
"script": "document.querySelector('.processing-status') !== null || document.querySelector('.chat-message-list')?.textContent?.includes('Working') || document.querySelector('.chat-message-list')?.textContent?.includes('Waiting')",
"expect": { "equals": "true" },
"note": "Processing status text (elapsed time / tool rounds / waiting) should be visible"
}
]
}
]
}
31 changes: 29 additions & 2 deletions PolyPilot/Components/ChatMessageList.razor
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,14 @@
{
<div class="chat-msg tool">
<span class="chat-msg-role">⏳</span>
<span class="chat-msg-text">@(string.IsNullOrEmpty(ActivityText) ? "Thinking…" : ActivityText)</span>
<span class="chat-msg-text">@(string.IsNullOrEmpty(ActivityText) ? GetProcessingStatus() : ActivityText)</span>
</div>
}
else
{
<div class="action-item action-default running">
<span class="action-dot"></span>
<span class="action-label">Thinking</span>
<span class="action-label">@GetProcessingStatus()</span>
</div>
}
}
Expand All @@ -110,6 +110,33 @@
[Parameter] public string? UserAvatarUrl { get; set; }
[Parameter] public ChatLayout Layout { get; set; } = ChatLayout.Default;
[Parameter] public ChatStyle Style { get; set; } = ChatStyle.Normal;
[Parameter] public DateTime? ProcessingStartedAt { get; set; }
[Parameter] public int ToolCallCount { get; set; }
[Parameter] public int ProcessingPhase { get; set; }

private string GetProcessingStatus()
{
// Phase 0: Sending, 1: Server connected, 2: Thinking, 3: Working (tools)
if (ProcessingPhase == 0)
return "Sending…";
if (ProcessingPhase == 1)
return "Server connected…";
if (ProcessingPhase == 2 && ToolCallCount == 0)
return "Thinking…";

var parts = new List<string> { "Working" };
if (ProcessingStartedAt.HasValue)
{
var elapsed = DateTime.UtcNow - ProcessingStartedAt.Value;
if (elapsed.TotalMinutes >= 1)
parts.Add($"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s");
else if (elapsed.TotalSeconds >= 5)
parts.Add($"{(int)elapsed.TotalSeconds}s");
}
if (ToolCallCount > 0)
parts.Add($"{ToolCallCount} tool call{(ToolCallCount != 1 ? "s" : "")}");
return string.Join(" · ", parts) + "…";
}

private string GetLayoutClass() => Layout switch
{
Expand Down
3 changes: 3 additions & 0 deletions PolyPilot/Components/ExpandedSessionView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@
ToolActivities="@ToolActivities"
ActivityText="@ActivityText"
IsProcessing="Session.IsProcessing"
ProcessingStartedAt="Session.ProcessingStartedAt"
ToolCallCount="Session.ToolCallCount"
ProcessingPhase="Session.ProcessingPhase"
Compact="false"
UserAvatarUrl="@UserAvatarUrl"
Layout="@Layout"
Expand Down
Loading
Loading