fix: clear stale context usage after reset (#2108)
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
const contextWindowMetadataKey = "context_window"
|
||||
|
||||
// contextWindowWriteGuard serializes context-window metadata writes for one
|
||||
// session and advances its generation at each successful agent reset boundary.
|
||||
// A context update that was observed before a reset cannot write after the
|
||||
// reset's clear, even though its persistence is performed asynchronously.
|
||||
type contextWindowWriteGuard struct {
|
||||
mu sync.Mutex
|
||||
generation uint64
|
||||
}
|
||||
|
||||
func (s *Service) contextWindowGuard(sessionID string) *contextWindowWriteGuard {
|
||||
guard := &contextWindowWriteGuard{}
|
||||
actual, _ := s.contextWindowGuards.LoadOrStore(sessionID, guard)
|
||||
return actual.(*contextWindowWriteGuard)
|
||||
}
|
||||
|
||||
func (s *Service) captureContextWindowGeneration(sessionID string) uint64 {
|
||||
guard := s.contextWindowGuard(sessionID)
|
||||
guard.mu.Lock()
|
||||
defer guard.mu.Unlock()
|
||||
return guard.generation
|
||||
}
|
||||
|
||||
// clearContextWindowForReset advances the session's context generation while
|
||||
// holding the same lock used by asynchronous context-window persistence.
|
||||
func (s *Service) clearContextWindowForReset(ctx context.Context, sessionID string) error {
|
||||
guard := s.contextWindowGuard(sessionID)
|
||||
guard.mu.Lock()
|
||||
defer guard.mu.Unlock()
|
||||
guard.generation++
|
||||
return s.repo.SetSessionMetadataKey(ctx, sessionID, contextWindowMetadataKey, nil)
|
||||
}
|
||||
|
||||
// persistContextWindowUpdate stores an update only when it belongs to the
|
||||
// current context generation. The bool is false when a reset superseded it.
|
||||
func (s *Service) persistContextWindowUpdate(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
generation uint64,
|
||||
contextWindowData map[string]interface{},
|
||||
) (bool, error) {
|
||||
guard := s.contextWindowGuard(sessionID)
|
||||
guard.mu.Lock()
|
||||
defer guard.mu.Unlock()
|
||||
if guard.generation != generation {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.repo.SetSessionMetadataKey(ctx, sessionID, contextWindowMetadataKey, contextWindowData); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func clearInMemoryContextWindow(session *models.TaskSession) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
if session.Metadata == nil {
|
||||
session.Metadata = make(map[string]interface{})
|
||||
}
|
||||
session.Metadata[contextWindowMetadataKey] = nil
|
||||
}
|
||||
@@ -337,6 +337,7 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C
|
||||
zap.String("task_id", data.TaskID))
|
||||
return
|
||||
}
|
||||
generation := s.captureContextWindowGeneration(data.TaskSessionID)
|
||||
|
||||
size, remaining, efficiency, source, ok := s.resolveContextWindowValues(ctx, data)
|
||||
if !ok {
|
||||
@@ -353,34 +354,52 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C
|
||||
|
||||
// Persist to database asynchronously using json_set to atomically set one
|
||||
// key without clobbering other metadata keys (plan_mode, prepare_result).
|
||||
// The generation check prevents a pre-reset update from resurrecting stale
|
||||
// usage after resetAgentContext clears the metadata.
|
||||
go func() {
|
||||
if err := s.repo.SetSessionMetadataKey(context.Background(), data.TaskSessionID, "context_window", contextWindowData); err != nil {
|
||||
persisted, err := s.persistAndPublishContextWindowUpdate(
|
||||
context.Background(), data.TaskID, data.TaskSessionID, generation, contextWindowData,
|
||||
)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.logger.Error("failed to update session with context window",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
case !persisted:
|
||||
s.logger.Debug("discarded stale context window update after reset",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.TaskSessionID))
|
||||
default:
|
||||
s.logger.Debug("persisted context window to session",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.TaskSessionID))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Broadcast context window update so the frontend can update in real-time.
|
||||
// This uses the existing session.state_changed event with metadata included.
|
||||
if s.eventBus != nil {
|
||||
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
|
||||
events.TaskSessionStateChanged,
|
||||
"orchestrator",
|
||||
map[string]interface{}{
|
||||
"task_id": data.TaskID,
|
||||
"session_id": data.TaskSessionID,
|
||||
"metadata": map[string]interface{}{
|
||||
"context_window": contextWindowData,
|
||||
},
|
||||
},
|
||||
))
|
||||
func (s *Service) persistAndPublishContextWindowUpdate(
|
||||
ctx context.Context,
|
||||
taskID, sessionID string,
|
||||
generation uint64,
|
||||
contextWindowData map[string]interface{},
|
||||
) (bool, error) {
|
||||
persisted, err := s.persistContextWindowUpdate(ctx, sessionID, generation, contextWindowData)
|
||||
if !persisted || err != nil || s.eventBus == nil {
|
||||
return persisted, err
|
||||
}
|
||||
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
|
||||
events.TaskSessionStateChanged,
|
||||
"orchestrator",
|
||||
map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"session_id": sessionID,
|
||||
"metadata": map[string]interface{}{
|
||||
contextWindowMetadataKey: contextWindowData,
|
||||
},
|
||||
},
|
||||
))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveContextWindowValues(ctx context.Context, data watcher.ContextWindowData) (int64, int64, float64, string, bool) {
|
||||
|
||||
@@ -320,3 +320,45 @@ func TestResolveContextWindowValues(t *testing.T) {
|
||||
require.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestContextWindowResetDropsStalePersistence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", "context_window", map[string]interface{}{
|
||||
"size": int64(200000), "used": int64(190000),
|
||||
}))
|
||||
|
||||
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
|
||||
staleGeneration := svc.captureContextWindowGeneration("s1")
|
||||
require.NoError(t, svc.clearContextWindowForReset(ctx, "s1"))
|
||||
|
||||
persisted, err := svc.persistContextWindowUpdate(ctx, "s1", staleGeneration, map[string]interface{}{
|
||||
"size": int64(200000), "used": int64(195000),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, persisted)
|
||||
|
||||
updated, err := repo.GetTaskSession(ctx, "s1")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, updated.Metadata["context_window"])
|
||||
}
|
||||
|
||||
func TestContextWindowResetDropsStaleBroadcast(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
eventBus := &recordingEventBus{}
|
||||
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
|
||||
svc.eventBus = eventBus
|
||||
|
||||
staleGeneration := svc.captureContextWindowGeneration("s1")
|
||||
require.NoError(t, svc.clearContextWindowForReset(ctx, "s1"))
|
||||
|
||||
persisted, err := svc.persistAndPublishContextWindowUpdate(ctx, "t1", "s1", staleGeneration, map[string]interface{}{
|
||||
"size": int64(200000), "used": int64(195000),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, persisted)
|
||||
require.Empty(t, eventBus.events)
|
||||
}
|
||||
|
||||
@@ -2080,6 +2080,16 @@ func (s *Service) resetAgentContext(ctx context.Context, taskID string, session
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(updateErr))
|
||||
}
|
||||
if updateErr := s.clearContextWindowForReset(ctx, sessionID); updateErr != nil {
|
||||
s.logger.Warn("failed to clear context window from session metadata",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(updateErr))
|
||||
}
|
||||
// Keep the in-memory event snapshot aligned with the new provider
|
||||
// conversation even when persistence fails; the initiating client clears
|
||||
// its cache after the provider reset succeeds and must not receive stale data
|
||||
// back through the final processOnEnter state event.
|
||||
clearInMemoryContextWindow(session)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
@@ -766,6 +767,155 @@ func TestProcessOnEnterPassthrough(t *testing.T) {
|
||||
func TestProcessOnEnterResetAgentContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("successful reset clears persisted context-window usage", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
session, _ := repo.GetTaskSession(ctx, "s1")
|
||||
session.Metadata = map[string]interface{}{
|
||||
"context_window": map[string]interface{}{"size": int64(200000), "used": int64(190000)},
|
||||
}
|
||||
if err := repo.UpdateTaskSession(ctx, session); err != nil {
|
||||
t.Fatalf("failed to seed context window: %v", err)
|
||||
}
|
||||
if err := repo.UpdateSessionMetadata(ctx, session.ID, session.Metadata); err != nil {
|
||||
t.Fatalf("failed to persist context window metadata: %v", err)
|
||||
}
|
||||
initial, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if initial.Metadata["context_window"] == nil {
|
||||
t.Fatal("precondition: context_window was not persisted")
|
||||
}
|
||||
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-reset")
|
||||
|
||||
svc := createTestServiceWithAgent(
|
||||
repo,
|
||||
newMockStepGetter(),
|
||||
newMockTaskRepo(),
|
||||
&mockAgentManager{repoForExecutionLookup: repo},
|
||||
)
|
||||
if !svc.resetAgentContext(ctx, "t1", session, "test") {
|
||||
t.Fatal("expected reset to succeed")
|
||||
}
|
||||
|
||||
updated, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if updated.Metadata["context_window"] != nil {
|
||||
t.Fatal("expected successful reset to clear context_window")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("processOnEnter publishes an explicit cleared context-window snapshot", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
session, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if err := repo.SetSessionMetadataKey(ctx, session.ID, "context_window", map[string]interface{}{
|
||||
"size": int64(200000), "used": int64(190000),
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to persist context window metadata: %v", err)
|
||||
}
|
||||
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-reset")
|
||||
|
||||
eventBus := &mockEventBus{}
|
||||
svc := createTestServiceWithAgent(
|
||||
repo,
|
||||
newMockStepGetter(),
|
||||
newMockTaskRepo(),
|
||||
&mockAgentManager{repoForExecutionLookup: repo},
|
||||
)
|
||||
svc.eventBus = eventBus
|
||||
step := &wfmodels.WorkflowStep{
|
||||
ID: "step2", WorkflowID: "wf1", Name: "Review Step",
|
||||
Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{
|
||||
{Type: wfmodels.OnEnterResetAgentContext},
|
||||
}},
|
||||
}
|
||||
|
||||
session, _ = repo.GetTaskSession(ctx, "s1")
|
||||
svc.processOnEnter(ctx, "t1", session, step, "review task")
|
||||
|
||||
published := eventBus.published()
|
||||
if len(published) < 2 {
|
||||
t.Fatalf("expected state settlement and final waiting events, got %d", len(published))
|
||||
}
|
||||
last := published[len(published)-1]
|
||||
if last.Subject != events.TaskSessionStateChanged {
|
||||
t.Fatalf("expected final event subject %q, got %q", events.TaskSessionStateChanged, last.Subject)
|
||||
}
|
||||
data := last.Event.Data.(map[string]interface{})
|
||||
metadata, ok := data["session_metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected session_metadata in final waiting event, got %#v", data["session_metadata"])
|
||||
}
|
||||
if _, exists := metadata["context_window"]; !exists {
|
||||
t.Fatalf("expected context_window key in final waiting event, got %#v", metadata)
|
||||
}
|
||||
if metadata["context_window"] != nil {
|
||||
t.Fatalf("expected final waiting event to clear context_window, got %#v", metadata["context_window"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("successful provider reset clears in-memory usage when metadata persistence fails", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
session, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if err := repo.SetSessionMetadataKey(ctx, session.ID, "context_window", map[string]interface{}{
|
||||
"size": int64(200000), "used": int64(190000),
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to persist context window metadata: %v", err)
|
||||
}
|
||||
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-reset")
|
||||
|
||||
svc := createTestServiceWithAgent(
|
||||
repo,
|
||||
newMockStepGetter(),
|
||||
newMockTaskRepo(),
|
||||
&mockAgentManager{repoForExecutionLookup: repo},
|
||||
)
|
||||
svc.repo = failSetSessionMetadataRepo{repoStore: repo}
|
||||
if !svc.resetAgentContext(ctx, "t1", session, "test") {
|
||||
t.Fatal("expected provider reset to remain successful")
|
||||
}
|
||||
if session.Metadata["context_window"] != nil {
|
||||
t.Fatalf("expected in-memory context_window to clear, got %#v", session.Metadata["context_window"])
|
||||
}
|
||||
persisted, err := repo.GetTaskSession(ctx, "s1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload session: %v", err)
|
||||
}
|
||||
if persisted.Metadata["context_window"] == nil {
|
||||
t.Fatal("expected failed metadata persistence to retain the stored reading")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failed reset retains persisted context-window usage", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
session, _ := repo.GetTaskSession(ctx, "s1")
|
||||
session.Metadata = map[string]interface{}{
|
||||
"context_window": map[string]interface{}{"size": int64(200000), "used": int64(190000)},
|
||||
}
|
||||
if err := repo.UpdateTaskSession(ctx, session); err != nil {
|
||||
t.Fatalf("failed to seed context window: %v", err)
|
||||
}
|
||||
if err := repo.UpdateSessionMetadata(ctx, session.ID, session.Metadata); err != nil {
|
||||
t.Fatalf("failed to persist context window metadata: %v", err)
|
||||
}
|
||||
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-reset")
|
||||
|
||||
svc := createTestServiceWithAgent(
|
||||
repo,
|
||||
newMockStepGetter(),
|
||||
newMockTaskRepo(),
|
||||
&mockAgentManager{repoForExecutionLookup: repo, restartProcessErr: errors.New("restart failed")},
|
||||
)
|
||||
if svc.resetAgentContext(ctx, "t1", session, "test") {
|
||||
t.Fatal("expected reset to fail")
|
||||
}
|
||||
|
||||
updated, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if updated.Metadata["context_window"] == nil {
|
||||
t.Fatal("expected failed reset to retain context_window")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reset_agent_context calls RestartAgentProcess", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
|
||||
@@ -503,6 +503,9 @@ type Service struct {
|
||||
// Session reset flags: sessionID -> true while resetAgentContext is restarting process.
|
||||
// Used to suppress stale ready events and avoid draining queued prompts mid-reset.
|
||||
resetInProgressSessions sync.Map
|
||||
// contextWindowGuards serializes live context usage writes and gives each
|
||||
// session a generation boundary that reset_agent_context can invalidate.
|
||||
contextWindowGuards sync.Map
|
||||
|
||||
// suppressToast: sessionID -> true. Set by failure handlers that create
|
||||
// guidance messages with actions. Cleared by updateTaskSessionState which
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
request: vi.fn(),
|
||||
clearContextWindow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ws/connection", () => ({
|
||||
getWebSocketClient: () => ({ request: mocks.request }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (
|
||||
selector: (state: { clearContextWindow: typeof mocks.clearContextWindow }) => unknown,
|
||||
) => selector({ clearContextWindow: mocks.clearContextWindow }),
|
||||
}));
|
||||
|
||||
import { ResetContextButton } from "./reset-context-button";
|
||||
|
||||
function renderResetButton() {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<ResetContextButton sessionId="session-1" />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ResetContextButton context-window invalidation", () => {
|
||||
beforeEach(() => {
|
||||
mocks.request.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it("clears the session usage cache after a successful reset", async () => {
|
||||
renderResetButton();
|
||||
|
||||
fireEvent.click(screen.getByTestId("reset-context-button"));
|
||||
fireEvent.click(await screen.findByTestId("reset-context-confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.request).toHaveBeenCalledWith(
|
||||
"session.reset_context",
|
||||
{ session_id: "session-1" },
|
||||
30000,
|
||||
);
|
||||
expect(mocks.clearContextWindow).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the session usage cache when reset fails", async () => {
|
||||
mocks.request.mockRejectedValue(new Error("reset failed"));
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
renderResetButton();
|
||||
|
||||
fireEvent.click(screen.getByTestId("reset-context-button"));
|
||||
await act(async () => {
|
||||
fireEvent.click(await screen.findByTestId("reset-context-confirm"));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mocks.request).toHaveBeenCalled());
|
||||
expect(mocks.clearContextWindow).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@kandev/ui/alert-dialog";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
|
||||
export function ResetContextButton({ sessionId }: { sessionId: string }) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const clearContextWindow = useAppStore((state) => state.clearContextWindow);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
setIsResetting(true);
|
||||
@@ -27,13 +29,14 @@ export function ResetContextButton({ sessionId }: { sessionId: string }) {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
await client.request("session.reset_context", { session_id: sessionId }, 30000);
|
||||
clearContextWindow(sessionId);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset agent context:", error);
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
}, [clearContextWindow, sessionId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,6 +4,24 @@ import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
type ContextWindowStoreWindow = Window & {
|
||||
__KANDEV_E2E_STORE__?: {
|
||||
getState: () => {
|
||||
tasks: { activeSessionId: string | null };
|
||||
setContextWindow: (
|
||||
sessionId: string,
|
||||
contextWindow: {
|
||||
size: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
efficiency: number;
|
||||
source: "acp" | "api";
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a task + session via the API and navigate directly to the session page.
|
||||
* Waits for the mock agent to complete its turn (idle input visible).
|
||||
@@ -35,6 +53,25 @@ async function seedTaskWithSession(
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Seed a high-usage reading that represents the stale value from before reset. */
|
||||
async function seedStaleContextWindow(testPage: Page): Promise<void> {
|
||||
await testPage.evaluate(() => {
|
||||
const store = (window as ContextWindowStoreWindow).__KANDEV_E2E_STORE__;
|
||||
if (!store) throw new Error("E2E store bridge is unavailable");
|
||||
|
||||
const sessionId = store.getState().tasks.activeSessionId;
|
||||
if (!sessionId) throw new Error("No active session is available");
|
||||
|
||||
store.getState().setContextWindow(sessionId, {
|
||||
size: 200_000,
|
||||
used: 190_000,
|
||||
remaining: 10_000,
|
||||
efficiency: 95,
|
||||
source: "acp",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ACP profile for the mock agent that fails on resume. The
|
||||
* mock-agent's ACP LoadSession handler exits 1 when --fail-on-resume is set,
|
||||
@@ -60,13 +97,18 @@ async function createACPProfileWithFailOnResume(apiClient: ApiClient, name: stri
|
||||
test.describe("Session recovery", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("reset context shows divider and agent responds fresh", async ({
|
||||
test("reset context hides stale usage until a fresh report arrives", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Reset Context Test");
|
||||
|
||||
await seedStaleContextWindow(testPage);
|
||||
const contextRing = testPage.getByRole("button", { name: "Context window: 95% used" });
|
||||
const contextIndicators = testPage.getByRole("button", { name: /^Context window:/ });
|
||||
await expect(contextRing).toBeVisible();
|
||||
|
||||
// Click reset context button — confirmation dialog should appear
|
||||
await session.resetContextButton().click();
|
||||
await expect(session.resetContextConfirm()).toBeVisible();
|
||||
@@ -79,10 +121,15 @@ test.describe("Session recovery", () => {
|
||||
|
||||
// Agent should restart and become idle again
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(session.resetContextButton()).toBeVisible();
|
||||
await expect(contextIndicators).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
// Verify agent works after reset by sending a new message
|
||||
await session.sendMessage("/e2e:simple-message");
|
||||
// The ring should return only after the agent reports fresh usage.
|
||||
await session.sendMessage("/background 1ms");
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(testPage.getByRole("button", { name: "Context window: 2% used" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("agent crash — start fresh session recovers", async ({ testPage, apiClient, seedData }) => {
|
||||
|
||||
@@ -29,6 +29,7 @@ function makeStore(overrides: Record<string, unknown> = {}) {
|
||||
setSessionAgentctlStatus: vi.fn(),
|
||||
setSessionFailureNotification: vi.fn(),
|
||||
setContextWindow: vi.fn(),
|
||||
clearContextWindow: vi.fn(),
|
||||
clearLegacyGitStatusEntry: vi.fn(),
|
||||
bumpSessionCommitsRefetch: vi.fn(),
|
||||
bumpWorkspaceFilesRefresh: vi.fn(),
|
||||
@@ -316,6 +317,54 @@ describe("session.state_changed context window provenance", () => {
|
||||
expect.objectContaining({ source: "acp" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the cached reading when a full session snapshot explicitly clears it", () => {
|
||||
const clearContextWindow = vi.fn();
|
||||
const store = makeStore({ clearContextWindow });
|
||||
const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
|
||||
|
||||
handler(
|
||||
makeMessage({
|
||||
task_id: "t-1",
|
||||
session_id: "s-1",
|
||||
session_metadata: { context_window: null },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(clearContextWindow).toHaveBeenCalledWith("s-1");
|
||||
});
|
||||
|
||||
it("clears the cached reading when a partial metadata patch explicitly clears it", () => {
|
||||
const clearContextWindow = vi.fn();
|
||||
const store = makeStore({ clearContextWindow });
|
||||
const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
|
||||
|
||||
handler(
|
||||
makeMessage({
|
||||
task_id: "t-1",
|
||||
session_id: "s-1",
|
||||
metadata: { context_window: null },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(clearContextWindow).toHaveBeenCalledWith("s-1");
|
||||
});
|
||||
|
||||
it("does not clear usage for unrelated metadata patches", () => {
|
||||
const clearContextWindow = vi.fn();
|
||||
const store = makeStore({ clearContextWindow });
|
||||
const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
|
||||
|
||||
handler(
|
||||
makeMessage({
|
||||
task_id: "t-1",
|
||||
session_id: "s-1",
|
||||
metadata: { plan_mode: true },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(clearContextWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.state_changed recoverable errors", () => {
|
||||
|
||||
@@ -262,14 +262,21 @@ function maybeFanOutOfficeRefetch(
|
||||
setOfficeTrigger("agents");
|
||||
}
|
||||
|
||||
/** Extract context window data from payload metadata and store it. */
|
||||
/** Extract context-window data or an explicit cache invalidation from a session event. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractContextWindow(store: StoreApi<AppState>, sessionId: string, payload: any): void {
|
||||
const metadata = payload.metadata;
|
||||
if (!metadata || typeof metadata !== "object") return;
|
||||
const contextWindow = (metadata as Record<string, unknown>).context_window;
|
||||
const metadataSources = [payload.metadata, payload.session_metadata];
|
||||
const metadata = metadataSources.find(
|
||||
(candidate) =>
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
Object.prototype.hasOwnProperty.call(candidate, "context_window"),
|
||||
) as Record<string, unknown> | undefined;
|
||||
if (!metadata) return;
|
||||
const contextWindow = metadata.context_window;
|
||||
const entry = parseContextWindowEntry(contextWindow, new Date().toISOString());
|
||||
if (entry) store.getState().setContextWindow(sessionId, entry);
|
||||
else store.getState().clearContextWindow(sessionId);
|
||||
}
|
||||
|
||||
/** Copy agentctl "ready" status from one session to another (same-task switch). */
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
spec: docs/specs/context-window-reset-freshness/spec.md
|
||||
created: 2026-07-31
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Context Window Reset Freshness
|
||||
|
||||
## Overview
|
||||
|
||||
Invalidate context-window data at the successful reset boundary, then teach the frontend to treat an explicit empty metadata value as cache invalidation. The initiating client also clears its local cache after a successful reset response so it does not wait for asynchronous session notifications. A focused browser regression proves that old usage disappears and a later fresh agent report can repopulate the ring.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
`ResetAgentContext` replaces the provider conversation but `resetAgentContext` only clears `acp_session_id`; it leaves `task_sessions.metadata.context_window` intact. The frontend also stores the last reading independently in `contextWindow.bySessionId`, and `extractContextWindow` only handles populated readings. Consequently, neither the reset response nor the subsequent session-state events invalidate the old ring; it remains visible until another agent usage frame overwrites it.
|
||||
|
||||
## Backend
|
||||
|
||||
### Successful reset metadata invalidation
|
||||
|
||||
- Update `apps/backend/internal/orchestrator/event_handlers_workflow.go` in `resetAgentContext` to clear `context_window` after `agentManager.ResetAgentContext` succeeds, alongside `acp_session_id` cleanup.
|
||||
- Keep the clear after the irreversible runtime reset so failed resets preserve the previous reading.
|
||||
- Log a metadata persistence failure without reporting the already-completed provider reset as failed; always clear the preloaded in-memory snapshot so the initiating client does not receive stale usage back through the final state event.
|
||||
- Extend `apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go` to prove successful resets clear both metadata keys and failed resets retain context-window metadata.
|
||||
|
||||
The public user-request path and workflow reset path share this helper, so the invariant applies to both without adding a new API or event type. After a successful metadata clear, the existing transition back to `WAITING_FOR_INPUT` reloads session metadata and publishes it through `session.state_changed`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Explicit context-window invalidation
|
||||
|
||||
- Update `apps/web/lib/ws/handlers/agent-session.ts` so context-window extraction recognizes both live `metadata` patches and full `session_metadata` snapshots. When either explicitly contains `context_window: null`, call `clearContextWindow`; when it contains a valid reading, keep the existing `setContextWindow` behavior; when the key is absent, do nothing.
|
||||
- Extend `apps/web/lib/ws/handlers/agent-session.test.ts` with populated, cleared, and unrelated-metadata cases.
|
||||
|
||||
### Initiating-client reset response
|
||||
|
||||
- Update `apps/web/components/task/chat/reset-context-button.tsx` to clear the session context-window cache only after `session.reset_context` resolves successfully.
|
||||
- Add `apps/web/components/task/chat/reset-context-button.test.tsx` to prove success clears the correct session and request failure retains the prior cache.
|
||||
|
||||
No layout, breakpoint, touch, or scroll behavior changes. Desktop and mobile already render the same `TokenUsageDisplay` from the shared session-runtime state; `apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts` remains the nearest mobile exemplar and covers the mobile trigger/tooltip path. The repair therefore uses shared-state tests rather than a new mobile composition.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** successful resets clear persisted context-window metadata while failed resets retain it.
|
||||
- **File:** `apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go`
|
||||
- **How:** extend the existing real-repository reset helper tests, including successful provider reset plus metadata-clear failure, and run only `TestProcessOnEnterResetAgentContext`.
|
||||
- **What:** explicit cleared metadata invalidates the frontend cache, while metadata without the key leaves it untouched.
|
||||
- **File:** `apps/web/lib/ws/handlers/agent-session.test.ts`
|
||||
- **How:** dispatch `session.state_changed` payloads against the focused store fake.
|
||||
- **What:** the initiating client clears usage after a successful reset response but not after rejection.
|
||||
- **File:** `apps/web/components/task/chat/reset-context-button.test.tsx`
|
||||
- **How:** render the control with mocked WebSocket and Zustand boundaries and exercise the confirmation action.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** **GIVEN** an idle session displays old context usage, **WHEN** the user resets context, **THEN** the old ring disappears; **WHEN** the fresh mock agent later reports usage, **THEN** the ring reappears with the fresh reading.
|
||||
- **File:** `apps/web/e2e/tests/session/session-recovery.spec.ts`
|
||||
- **What to verify:** the existing reset dialog/divider/idle flow, absence of the old context trigger after reset, and a fresh context trigger after a mock-agent command that emits `usage_update`.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Execution is sequential in the primary conversation.
|
||||
|
||||
- [x] [Task 01: Invalidate context-window state on successful reset](task-01-invalidate-reset-context-usage.md) — done
|
||||
- [x] [Task 02: Cover reset freshness end to end](task-02-context-reset-e2e.md) — done
|
||||
|
||||
## Validation commands
|
||||
|
||||
- `cd apps/backend && go test -run TestProcessOnEnterResetAgentContext ./internal/orchestrator`
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run lib/ws/handlers/agent-session.test.ts components/task/chat/reset-context-button.test.tsx`
|
||||
- `cd apps/web && pnpm e2e:run --host tests/session/session-recovery.spec.ts -- --grep "reset context hides stale usage"`
|
||||
|
||||
## Risks and boundaries
|
||||
|
||||
- Session-state payloads use both `metadata` for narrow context updates and `session_metadata` for full state transitions; invalidation must support both without clearing on unrelated partial metadata.
|
||||
- Reset failure occurs before metadata invalidation and must preserve the old reading.
|
||||
- The existing reset control remains intentionally hidden while busy; changing prompt-admission or cancellation semantics is outside this fix.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: "01-invalidate-reset-context-usage"
|
||||
title: "Invalidate context-window state on successful reset"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/context-window-reset-freshness/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Invalidate context-window state on successful reset
|
||||
|
||||
## Intent
|
||||
|
||||
Remove the previous provider conversation's context-window reading from persistence and every active frontend cache once a reset succeeds, while retaining it when reset fails.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A successful shared reset clears `task_sessions.metadata.context_window` and the initiating client's `contextWindow.bySessionId[sessionId]` entry.
|
||||
- `session.state_changed` clears cached usage only when `metadata` or `session_metadata` explicitly contains an empty `context_window`; unrelated metadata does not clear it.
|
||||
- Failed resets retain the prior persisted and frontend reading.
|
||||
- If the provider reset succeeds but metadata clearing fails, Kandev logs the persistence failure, keeps the reset response successful, and the initiating client still receives an explicit in-memory clear.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Extend `TestProcessOnEnterResetAgentContext` with successful-clear, failed-retention, and successful-provider-reset plus failed-metadata-clear assertions; run it and confirm the successful-clear assertion fails.
|
||||
2. Add frontend handler cases for `context_window: null` and unrelated metadata; run them and confirm the null case fails.
|
||||
3. Add the reset-button success/failure cache tests; run them and confirm the success case fails.
|
||||
4. Implement the smallest backend and frontend changes, rerun the exact checks, and refactor without widening behavior.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/orchestrator/event_handlers_workflow.go`
|
||||
- `apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go`
|
||||
- `apps/web/lib/ws/handlers/agent-session.ts`
|
||||
- `apps/web/lib/ws/handlers/agent-session.test.ts`
|
||||
- `apps/web/components/task/chat/reset-context-button.tsx`
|
||||
- `apps/web/components/task/chat/reset-context-button.test.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential` — backend persistence, frontend event invalidation, and initiating-client invalidation form one behavioral boundary and should converge before E2E work.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test -run TestProcessOnEnterResetAgentContext ./internal/orchestrator`
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run lib/ws/handlers/agent-session.test.ts components/task/chat/reset-context-button.test.tsx`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec `What`, `Persistence guarantees`, `Failure modes`, and first four scenarios.
|
||||
- Existing model-change invalidation in `apps/web/lib/ws/handlers/session-models.ts`.
|
||||
- Existing context persistence in `apps/backend/internal/orchestrator/event_handlers_git.go`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Result: RED reproduced the persisted-cache bug and the frontend null-handling gap. GREEN passed `go test -v -run TestProcessOnEnterResetAgentContext ./internal/orchestrator` (9 tests) and `pnpm --filter @kandev/web test -- --run lib/ws/handlers/agent-session.test.ts components/task/chat/reset-context-button.test.tsx` (47 tests). The reset boundary now clears persisted usage after successful reset, explicit null session metadata clears subscribed clients, a metadata-clear failure keeps the provider reset successful while clearing the in-memory snapshot, and the initiating client clears only after a successful response.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
id: "02-context-reset-e2e"
|
||||
title: "Cover reset freshness end to end"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-invalidate-reset-context-usage"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/context-window-reset-freshness/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Cover reset freshness end to end
|
||||
|
||||
## Intent
|
||||
|
||||
Prove through the real reset UI and mock-agent runtime that the old ring disappears after reset and only a fresh agent usage report makes it return.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The browser regression starts with a visible seeded context-window ring, completes the existing reset confirmation flow, and observes that the ring is absent while the fresh conversation has no report.
|
||||
- A later mock-agent command that emits `usage_update` makes the ring visible with the new conversation's reading.
|
||||
- The reset control is available again once the reset settles and the session returns to idle; it remains hidden during the busy reset turn.
|
||||
- The existing reset divider, idle recovery, and post-reset message flow remain covered.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Extend the existing reset-context recovery scenario with a seeded stale ring and
|
||||
fresh-report assertion.
|
||||
2. Run the focused scenario against a fresh production build after Task 01; it
|
||||
passes with the reset invalidation and fresh usage propagation in place.
|
||||
3. Refactor only shared seeding/page-object code needed to keep the scenario readable.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/session/session-recovery.spec.ts`
|
||||
- `apps/web/e2e/pages/session-page.ts` only if a stable context-ring locator is not already adequate
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential` — this test depends on both backend and frontend invalidation behavior.
|
||||
|
||||
## Mobile parity
|
||||
|
||||
This change normalizes shared session-runtime state and does not alter composition, controls, touch behavior, scrolling, or breakpoints. Both toolbars consume the same `TokenUsageDisplay`; existing `apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts` covers the mobile rendering and touch disclosure. No new mobile-specific scenario is required.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/web && pnpm e2e:run --host tests/session/session-recovery.spec.ts -- --grep "reset context hides stale usage"`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Task 01 completed behavior.
|
||||
- Existing reset flow in `apps/web/e2e/tests/session/session-recovery.spec.ts`.
|
||||
- Mock-agent `/background 1ms` usage update path in `apps/backend/cmd/mock-agent/handler.go`.
|
||||
|
||||
## Verification result
|
||||
|
||||
The focused Playwright regression passed against a fresh backend and Vite build:
|
||||
|
||||
```text
|
||||
cd apps/web && pnpm e2e:run --host tests/session/session-recovery.spec.ts -- --grep "reset context hides stale usage"
|
||||
1 passed (9.4s)
|
||||
```
|
||||
|
||||
No browser artifacts or blockers were produced. The regression uses the existing
|
||||
reset dialog/divider flow, verifies the control returns at idle, and uses the
|
||||
mock agent's `/background 1ms` usage update.
|
||||
@@ -149,6 +149,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
|
||||
| [embedded-vscode-executor-availability](ui/embedded-vscode-executor-availability.md) | approved |
|
||||
| [embedded-vscode-windows-availability](ui/embedded-vscode-windows-availability.md) | archived; superseded by embedded-vscode-executor-availability |
|
||||
| [ws-connectivity-warning](ui/ws-connectivity-warning.md) | approved |
|
||||
| [context-window reset freshness](context-window-reset-freshness/spec.md) | shipped |
|
||||
|
||||
## system-page/ — operational diagnostics & maintenance UI
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-07-31
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Context Window Reset Freshness
|
||||
|
||||
## Why
|
||||
|
||||
The context-window indicator can continue showing the previous conversation's nearly-full usage after the user resets an agent session. That reading describes context which no longer exists and makes a fresh conversation look almost exhausted.
|
||||
|
||||
## What
|
||||
|
||||
- A successful agent-context reset invalidates the session's last context-window reading.
|
||||
- The context-window indicator stays hidden after reset until the fresh agent conversation reports a new usable reading.
|
||||
- The invalidation is reflected in persisted session metadata and propagated to subscribed clients, so the stale reading does not return after refresh or in another open client.
|
||||
- A new context-window report after reset renders normally and becomes the new persisted reading.
|
||||
- A failed reset retains the prior reading because the agent conversation was not replaced.
|
||||
- The reset action remains available only while the session is idle; running sessions continue to hide the action because the backend does not accept context resets while busy.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
- A successful reset clears `task_sessions.metadata.context_window` before the reset flow returns the session to its idle state.
|
||||
- Live frontend context-window state is a cache of agent-reported or persisted session metadata. An explicit cleared value invalidates that cache; absence of a fresh report must not be interpreted as zero usage.
|
||||
- If the provider reset succeeds but metadata clearing fails, Kandev logs the persistence failure, keeps the reset response successful, and publishes an explicit in-memory clear so the initiating client still hides stale usage.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If the agent reset fails, Kandev restores the idle session state and leaves the previous context-window reading intact.
|
||||
- If clearing persisted metadata fails after the provider conversation has already reset, Kandev does not attempt to undo the reset. It logs the persistence failure, keeps the reset response successful, and the initiating client still hides its stale cached reading.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** an idle session displays a nearly-full context-window ring, **WHEN** the user successfully resets agent context, **THEN** the ring disappears before the user starts the next turn.
|
||||
- **GIVEN** a successful context reset has hidden the ring, **WHEN** the fresh agent conversation has not reported context usage, **THEN** the ring remains absent across session-state updates and page refresh.
|
||||
- **GIVEN** a successful context reset has hidden the ring, **WHEN** the fresh agent conversation reports context-window usage, **THEN** the ring reappears using only that new reading.
|
||||
- **GIVEN** an idle session displays context-window usage, **WHEN** its context reset fails, **THEN** the previous ring remains visible.
|
||||
- **GIVEN** the provider reset succeeds but clearing persisted metadata fails, **WHEN** the reset settles, **THEN** Kandev logs the persistence failure, the initiating client hides stale usage, and the provider reset is not reported as failed.
|
||||
- **GIVEN** the user sends a new message after reset, **WHEN** the session becomes busy, **THEN** the reset action is hidden until the session is idle again.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Allowing context reset while an agent turn is running.
|
||||
- Changing the ring's visual design, thresholds, tooltip, or toolbar placement.
|
||||
- Estimating fresh-session usage before the agent reports it.
|
||||
Reference in New Issue
Block a user