fix(acp): keep todo indicator alive across session resume (#2315)

This commit is contained in:
irium
2026-08-06 13:23:01 +02:00
committed by GitHub
parent 09f2f7b615
commit fef3da278a
2 changed files with 40 additions and 30 deletions
@@ -506,21 +506,7 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers
// the frontend doesn't render a stuck "watching" card.
a.sweepMonitorsOnReplayEnd(sessionID)
if replayPlan != nil {
entries := make([]PlanEntry, len(replayPlan.Entries))
for i, e := range replayPlan.Entries {
entries[i] = PlanEntry{
Description: e.Content,
Status: string(e.Status),
Priority: string(e.Priority),
}
}
a.sendUpdate(AgentEvent{
Type: streams.EventTypePlan,
SessionID: sessionID,
PlanEntries: entries,
})
}
a.emitReplayPlan(sessionID, replayPlan)
// Emit session status event to normalize with other adapters.
// This eliminates the need for ReportsStatusViaStream flag.
@@ -544,6 +530,31 @@ func (a *Adapter) ResetSession(ctx context.Context, mcpServers []types.McpServer
return a.NewSession(ctx, mcpServers)
}
// emitReplayPlan re-emits the plan captured during session/load replay so the todo
// indicator survives a resume. An empty plan is dropped: on the ACP ingest path an
// empty entry list carries no information (same reasoning as extractTodoItems), and
// re-emitting it would both wipe the live indicator and persist an empty todo snapshot
// that shadows the last real one in the chat timeline. A genuine "agent cleared the
// todos" still arrives as a live plan update and keeps its persisted empty snapshot.
func (a *Adapter) emitReplayPlan(sessionID string, replayPlan *acp.SessionUpdatePlan) {
if replayPlan == nil || len(replayPlan.Entries) == 0 {
return
}
entries := make([]PlanEntry, len(replayPlan.Entries))
for i, e := range replayPlan.Entries {
entries[i] = PlanEntry{
Description: e.Content,
Status: string(e.Status),
Priority: string(e.Priority),
}
}
a.sendUpdate(AgentEvent{
Type: streams.EventTypePlan,
SessionID: sessionID,
PlanEntries: entries,
})
}
// emitInitialModeState emits a session_mode event from the session response's Modes field.
// Called after session/new and session/load to provide the initial mode state.
func (a *Adapter) emitInitialModeState(modes *acp.SessionModeState) {
@@ -212,21 +212,7 @@ func TestLoadSuppression_PlanReemittedAfterLoad(t *testing.T) {
a.isLoadingSession = false
a.mu.Unlock()
if replayPlan != nil {
entries := make([]PlanEntry, len(replayPlan.Entries))
for i, e := range replayPlan.Entries {
entries[i] = PlanEntry{
Description: e.Content,
Status: string(e.Status),
Priority: string(e.Priority),
}
}
a.sendUpdate(AgentEvent{
Type: streams.EventTypePlan,
SessionID: "s1",
PlanEntries: entries,
})
}
a.emitReplayPlan("s1", replayPlan)
// Now we should see the re-emitted plan.
events = drainEvents(a)
@@ -254,6 +240,19 @@ func TestLoadSuppression_PlanReemittedAfterLoad(t *testing.T) {
a.mu.RUnlock()
}
func TestLoadSuppression_EmptyReplayPlanNotReemitted(t *testing.T) {
a := newTestAdapter()
// A replay that ends with an empty plan must not re-emit it: the event would
// clear the live todo indicator and persist an empty todo snapshot that hides
// the last real one.
a.emitReplayPlan("s1", &acp.SessionUpdatePlan{Entries: []acp.PlanEntry{}})
if events := drainEvents(a); len(events) != 0 {
t.Fatalf("expected no event for an empty replay plan, got %d: %+v", len(events), events)
}
}
func TestLoadSuppression_PostReplayEventsPassThrough(t *testing.T) {
a := newTestAdapter()