diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go index 27ff2f88a9..35c4d0c6c3 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go @@ -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) { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/load_suppression_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/load_suppression_test.go index 3373efb04f..a7b62258b3 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/load_suppression_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/load_suppression_test.go @@ -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()