From 327f7d4ec0e7ea6d1111de330996a08c41490f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 16 Jun 2026 22:27:28 +0100 Subject: [PATCH] fix: persist agent error dismissal (#1397) --- .../task/handlers/process_handlers_test.go | 4 + .../internal/task/handlers/task_handlers.go | 1 + .../task/handlers/task_http_handlers.go | 20 ++++ apps/backend/internal/task/models/models.go | 39 +++++- .../internal/task/repository/interface.go | 2 + .../task/repository/sqlite/session.go | 23 ++++ .../task/repository/sqlite/session_test.go | 40 +++++++ .../internal/task/service/service_sessions.go | 23 ++++ .../internal/task/service/service_test.go | 54 +++++++++ .../task/chat/message-list-native.tsx | 2 - .../task/chat/message-list-shared.test.tsx | 32 +++-- .../task/chat/message-list-shared.tsx | 48 ++++++-- .../task/chat/message-list-virtuoso.tsx | 3 - .../task/chat/use-chat-panel-state.ts | 3 + apps/web/e2e/helpers/api-client.ts | 1 + apps/web/e2e/pages/session-page.ts | 4 + .../session/agent-error-indicator.spec.ts | 20 +++- apps/web/hooks/use-processed-messages.test.ts | 52 ++++++++ apps/web/hooks/use-processed-messages.ts | 111 +++++++++++++++--- apps/web/lib/api/domains/session-api.ts | 14 +++ apps/web/lib/session-last-agent-error.test.ts | 24 ++++ apps/web/lib/session-last-agent-error.ts | 4 + apps/web/lib/task-agent-error.test.ts | 13 ++ 23 files changed, 494 insertions(+), 43 deletions(-) diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index d757971a3d..a0d206aee3 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -12,6 +12,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "unsafe" "github.com/gin-gonic/gin" @@ -500,6 +501,9 @@ func (m *mockRepository) UpdateSessionMetadata(ctx context.Context, sessionID st func (m *mockRepository) SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error { return nil } +func (m *mockRepository) DismissLastAgentError(_ context.Context, _ string, _ models.LastAgentError, _ time.Time) (bool, error) { + return true, nil +} func (m *mockRepository) GetLastAgentMessage(_ context.Context, _ string) (string, error) { return "", nil } diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 088bf99ae3..ac93a733d5 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -83,6 +83,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.GET("/tasks/:id", h.httpGetTask) api.GET("/tasks/:id/context", h.httpGetTaskContext) api.GET("/task-sessions/:id", h.httpGetTaskSession) + api.POST("/task-sessions/:id/last-agent-error/dismiss", h.httpDismissLastAgentError) api.GET("/tasks/:id/sessions", h.httpListTaskSessions) api.POST("/tasks/:id/sessions/ensure", h.httpEnsureTaskSession) api.GET("/tasks/:id/environment", h.httpGetTaskEnvironment) diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 2166325ff4..205bbeec93 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -257,6 +257,26 @@ func (h *TaskHandlers) httpGetTaskSession(c *gin.Context) { }) } +type dismissLastAgentErrorRequest struct { + Stamp string `json:"stamp"` +} + +func (h *TaskHandlers) httpDismissLastAgentError(c *gin.Context) { + var req dismissLastAgentErrorRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()}) + return + } + session, err := h.service.DismissLastAgentError(c.Request.Context(), c.Param("id"), req.Stamp) + if err != nil { + handleNotFound(c, h.logger, err, "task session not found") + return + } + c.JSON(http.StatusOK, dto.GetTaskSessionResponse{ + Session: dto.FromTaskSession(session), + }) +} + func (h *TaskHandlers) httpListSessionTurns(c *gin.Context) { sessionID := c.Param("id") if sessionID == "" { diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 5e2ecb0058..5fa794f407 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "errors" "maps" "time" @@ -103,9 +104,41 @@ const SessionMetaKeyLastAgentError = "last_agent_error" // LastAgentError is persisted under TaskSession.Metadata[SessionMetaKeyLastAgentError]. type LastAgentError struct { - Message string `json:"message"` - OccurredAt time.Time `json:"occurred_at"` - AgentExecutionID string `json:"agent_execution_id,omitempty"` + Message string `json:"message"` + OccurredAt time.Time `json:"occurred_at"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + DismissedAt *time.Time `json:"dismissed_at,omitempty"` +} + +func LoadLastAgentError(metadata map[string]interface{}) (LastAgentError, bool) { + if metadata == nil { + return LastAgentError{}, false + } + raw, ok := metadata[SessionMetaKeyLastAgentError] + if !ok || raw == nil { + return LastAgentError{}, false + } + var out LastAgentError + if err := mapToLastAgentError(raw, &out); err != nil || out.Message == "" { + return LastAgentError{}, false + } + return out, true +} + +func mapToLastAgentError(raw interface{}, out *LastAgentError) error { + data, err := json.Marshal(raw) + if err != nil { + return err + } + return json.Unmarshal(data, out) +} + +func (e LastAgentError) Stamp() string { + return e.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + e.Message +} + +func (e LastAgentError) IsDismissed() bool { + return e.DismissedAt != nil && !e.DismissedAt.IsZero() } // PendingStepCompletionSignal source values. diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index fb94704812..ba2b8e753f 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -2,6 +2,7 @@ package repository import ( "context" + "time" agentdto "github.com/kandev/kandev/internal/agent/dto" "github.com/kandev/kandev/internal/task/models" @@ -165,6 +166,7 @@ type SessionRepository interface { UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error + DismissLastAgentError(ctx context.Context, sessionID string, expected models.LastAgentError, dismissedAt time.Time) (bool, error) GetLastAgentMessage(ctx context.Context, sessionID string) (string, error) } diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index fce2b472b2..e8f03706be 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -633,6 +633,29 @@ func (r *Repository) SetSessionMetadataKey(ctx context.Context, sessionID, key s return nil } +func (r *Repository) DismissLastAgentError(ctx context.Context, sessionID string, expected models.LastAgentError, dismissedAt time.Time) (bool, error) { + next := expected + next.DismissedAt = &dismissedAt + valueJSON, err := json.Marshal(next) + if err != nil { + return false, fmt.Errorf("failed to serialize metadata value: %w", err) + } + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_sessions + SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, '$.last_agent_error', json(?)), + updated_at = ? + WHERE id = ? + AND json_extract(metadata, '$.last_agent_error.message') = ? + AND json_extract(metadata, '$.last_agent_error.occurred_at') = ? + `), string(valueJSON), now, sessionID, expected.Message, expected.OccurredAt.UTC().Format(time.RFC3339Nano)) + if err != nil { + return false, err + } + rows, _ := result.RowsAffected() + return rows > 0, nil +} + // GetLastAgentMessage returns the content of the most recent agent message in a session. func (r *Repository) GetLastAgentMessage(ctx context.Context, sessionID string) (string, error) { var content string diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index 67527035a3..50fad030f0 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -511,6 +511,46 @@ func TestUpdateTaskSessionWithMetadataRejectsInvalidMetadataBeforeStateWrite(t * } } +func TestDismissLastAgentErrorDoesNotOverwriteNewerError(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + + seedForMsgTest(t, repo, "task-error", "sess-error", "turn-error") + oldErr := models.LastAgentError{ + Message: "old error", + OccurredAt: time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC), + } + newErr := models.LastAgentError{ + Message: "new error", + OccurredAt: time.Date(2026, 6, 14, 10, 5, 0, 0, time.UTC), + } + if err := repo.SetSessionMetadataKey(ctx, "sess-error", models.SessionMetaKeyLastAgentError, oldErr); err != nil { + t.Fatalf("seed old error: %v", err) + } + if err := repo.SetSessionMetadataKey(ctx, "sess-error", models.SessionMetaKeyLastAgentError, newErr); err != nil { + t.Fatalf("seed new error: %v", err) + } + + updated, err := repo.DismissLastAgentError(ctx, "sess-error", oldErr, time.Now().UTC()) + if err != nil { + t.Fatalf("dismiss stale error: %v", err) + } + if updated { + t.Fatalf("expected stale dismiss to be ignored") + } + session, err := repo.GetTaskSession(ctx, "sess-error") + if err != nil { + t.Fatalf("get session: %v", err) + } + got, ok := models.LoadLastAgentError(session.Metadata) + if !ok { + t.Fatalf("expected last agent error metadata") + } + if got.Message != newErr.Message || got.IsDismissed() { + t.Fatalf("last agent error = %#v, want undismissed newer error", got) + } +} + func sessionCancellationMetadata(t *testing.T, repo *Repository, sessionID string) (string, sql.NullTime, time.Time) { t.Helper() var errorMessage string diff --git a/apps/backend/internal/task/service/service_sessions.go b/apps/backend/internal/task/service/service_sessions.go index 8c27f2bd6d..4b3280a8d5 100644 --- a/apps/backend/internal/task/service/service_sessions.go +++ b/apps/backend/internal/task/service/service_sessions.go @@ -63,6 +63,29 @@ func (s *Service) GetTaskSession(ctx context.Context, sessionID string) (*models return s.sessions.GetTaskSession(ctx, sessionID) } +func (s *Service) DismissLastAgentError(ctx context.Context, sessionID, stamp string) (*models.TaskSession, error) { + session, err := s.sessions.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, err + } + lastErr, ok := models.LoadLastAgentError(session.Metadata) + if !ok { + return session, nil + } + if stamp != "" && lastErr.Stamp() != stamp { + return session, nil + } + now := time.Now().UTC() + updated, err := s.sessions.DismissLastAgentError(context.WithoutCancel(ctx), sessionID, lastErr, now) + if err != nil { + return nil, err + } + if !updated { + return session, nil + } + return s.sessions.GetTaskSession(ctx, sessionID) +} + // GetPrimarySession returns the primary session for a task. func (s *Service) GetPrimarySession(ctx context.Context, taskID string) (*models.TaskSession, error) { return s.sessions.GetPrimarySessionByTaskID(ctx, taskID) diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 8f52f79b9d..05f97e95e1 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -662,6 +662,60 @@ func setupTestSession(t *testing.T, repo *sqliterepo.Repository) string { return session.ID } +func TestService_DismissLastAgentError(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + occurredAt := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) + lastErr := models.LastAgentError{ + Message: "peer disconnected before response", + OccurredAt: occurredAt, + AgentExecutionID: "exec-1", + } + if err := repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyLastAgentError, lastErr); err != nil { + t.Fatalf("seed last agent error: %v", err) + } + + session, err := svc.DismissLastAgentError(ctx, sessionID, lastErr.Stamp()) + if err != nil { + t.Fatalf("dismiss last agent error: %v", err) + } + got, ok := models.LoadLastAgentError(session.Metadata) + if !ok { + t.Fatalf("expected last agent error metadata after dismiss") + } + if !got.IsDismissed() { + t.Fatalf("expected dismissed last agent error, got %#v", got) + } +} + +func TestService_DismissLastAgentErrorIgnoresStaleStamp(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + lastErr := models.LastAgentError{ + Message: "fresh error", + OccurredAt: time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC), + } + if err := repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyLastAgentError, lastErr); err != nil { + t.Fatalf("seed last agent error: %v", err) + } + + session, err := svc.DismissLastAgentError(ctx, sessionID, "stale:error") + if err != nil { + t.Fatalf("dismiss last agent error: %v", err) + } + got, ok := models.LoadLastAgentError(session.Metadata) + if !ok { + t.Fatalf("expected last agent error metadata after stale dismiss") + } + if got.IsDismissed() { + t.Fatalf("expected stale dismiss to leave error visible, got %#v", got) + } +} + func setupTestTurn(t *testing.T, repo *sqliterepo.Repository, sessionID, taskID, turnID string) string { t.Helper() ctx := context.Background() diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index ccb521bc74..a566efd9eb 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -9,7 +9,6 @@ import { MessageRenderer } from "@/components/task/chat/message-renderer"; import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; import { type MessageListProps, - LastAgentErrorNotice, MessageListStatus, MessageItem, getItemKey, @@ -251,7 +250,6 @@ export const NativeMessageList = memo(function NativeMessageList({ ); })} - {(footerActionMessages ?? []).map((msg: Message) => ( diff --git a/apps/web/components/task/chat/message-list-shared.test.tsx b/apps/web/components/task/chat/message-list-shared.test.tsx index 11810eed57..45e62ffaeb 100644 --- a/apps/web/components/task/chat/message-list-shared.test.tsx +++ b/apps/web/components/task/chat/message-list-shared.test.tsx @@ -21,6 +21,7 @@ const mockStoreState = vi.hoisted(() => ({ }, dismissedAgentErrors: {} as Record, dismissAgentError: () => {}, + setTaskSession: () => {}, })); vi.mock("@/components/task/chat/message-renderer", () => ({ @@ -37,6 +38,9 @@ vi.mock("@/components/session/prepare-progress", () => ({ })); vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), + useAppStoreApi: () => ({ + getState: () => mockStoreState, + }), })); vi.mock("@/hooks/use-lazy-load-messages", () => ({ useLazyLoadMessages: () => ({ @@ -53,9 +57,11 @@ vi.mock("@kandev/ui/pannel-session", () => ({
{children}
), })); +vi.mock("@/lib/api/domains/session-api", () => ({ + dismissLastAgentError: vi.fn(), +})); import { MessageItem } from "./message-list-shared"; -import { VirtuosoMessageList } from "./message-list-virtuoso"; const item: RenderItem = { type: "message", message: { id: "m1" } as Message }; const noop = () => {}; @@ -110,18 +116,26 @@ describe("MessageItem memo boundary", () => { }); }); -describe("VirtuosoMessageList empty state", () => { +describe("MessageItem agent error notice", () => { it("shows retained agent errors even when there are no messages", () => { render( - , ); diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx index 02ae5bf110..9698d70f8a 100644 --- a/apps/web/components/task/chat/message-list-shared.tsx +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -9,8 +9,13 @@ import type { RenderItem } from "@/hooks/use-processed-messages"; import { MessageRenderer } from "@/components/task/chat/message-renderer"; import { TurnGroupMessage } from "@/components/task/chat/messages/turn-group-message"; import { PrepareProgress } from "@/components/session/prepare-progress"; -import { useAppStore } from "@/components/state-provider"; -import { lastAgentErrorStamp, readLastAgentError } from "@/lib/session-last-agent-error"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { dismissLastAgentError } from "@/lib/api/domains/session-api"; +import { + type LastAgentError, + lastAgentErrorStamp, + readLastAgentError, +} from "@/lib/session-last-agent-error"; export type MessageListProps = { items: RenderItem[]; @@ -29,7 +34,12 @@ export type MessageListProps = { }; export function getItemKey(item: RenderItem): string { - if (item.type === "turn_group" || item.type === "prepare_progress") return item.id; + if ( + item.type === "turn_group" || + item.type === "prepare_progress" || + item.type === "agent_error_notice" + ) + return item.id; return item.message.id; } @@ -49,21 +59,36 @@ export function getLastTurnGroupId(items: RenderItem[]) { // after the agent resumes — so the user can read the full error message at // their own pace. The sidebar icon, by contrast, also auto-hides once the // agent posts a new message (see agentErrorMessageForTask). -export function LastAgentErrorNotice({ sessionId }: { sessionId: string | null }) { - const metadata = useAppStore((state) => - sessionId ? state.taskSessions.items[sessionId]?.metadata : null, - ); - const error = readLastAgentError(metadata); +export function LastAgentErrorNotice({ + sessionId, + error, +}: { + sessionId: string | null; + error: LastAgentError | null; +}) { const stamp = error ? lastAgentErrorStamp(error) : ""; const dismissedStamp = useAppStore((state) => sessionId ? state.dismissedAgentErrors[sessionId] : undefined, ); const dismissAgentError = useAppStore((state) => state.dismissAgentError); + const setTaskSession = useAppStore((state) => state.setTaskSession); + const store = useAppStoreApi(); const dismiss = useCallback(() => { if (!sessionId || !stamp) return; - dismissAgentError(sessionId, stamp); - }, [dismissAgentError, sessionId, stamp]); + void dismissLastAgentError(sessionId, stamp) + .then((resp) => { + const current = readLastAgentError( + store.getState().taskSessions.items[sessionId]?.metadata, + ); + if (current && lastAgentErrorStamp(current) !== stamp) return; + dismissAgentError(sessionId, stamp); + setTaskSession(resp.session); + }) + .catch((err: unknown) => { + console.error("Failed to dismiss last agent error", err); + }); + }, [dismissAgentError, sessionId, stamp, setTaskSession, store]); if (!error || dismissedStamp === stamp) return null; @@ -178,6 +203,9 @@ export const MessageItem = memo(function MessageItem({ if (item.type === "prepare_progress") { return ; } + if (item.type === "agent_error_notice") { + return ; + } if (item.type === "turn_group") { return ( ( <> - {footerActions.map((msg) => ( @@ -492,7 +490,6 @@ export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: Mess messagesCount={messages.length} onLoadMore={loadMore} /> - {footerActions.map((msg) => ( diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 4ecabf63cc..605c52c979 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -26,6 +26,7 @@ import type { DiffComment } from "@/lib/diff/types"; import type { PlanComment, PRFeedbackComment } from "@/lib/state/slices/comments"; import type { ActiveDocument } from "@/lib/state/slices/ui/types"; import type { BuiltInPreset } from "@/lib/state/layout-manager/presets"; +import { readLastAgentError } from "@/lib/session-last-agent-error"; const EMPTY_CONTEXT_FILES: ContextFile[] = []; const PLAN_CONTEXT_PATH = "plan:context"; @@ -389,8 +390,10 @@ function useSessionData( isLoading: messagesLoading, hasMore: hasOlderMessages, } = useSessionMessages(resolvedSessionId); + const lastAgentError = useMemo(() => readLastAgentError(session?.metadata), [session?.metadata]); const processed = useProcessedMessages(messages, taskId, resolvedSessionId, taskDescription, { hasOlderMessages, + lastAgentError, }); const { sessionModel, activeModel } = useSessionModel( resolvedSessionId, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 1feadca0a0..35eb276c5f 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1124,6 +1124,7 @@ export class ApiClient { worktree_path?: string; worktree_branch?: string; error_message?: string; + metadata?: Record; }>; total: number; }> { diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 328e9745e5..35c53b9e3d 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -246,6 +246,10 @@ export class SessionPage { }); } + activeSidebarTaskItem(title: string): Locator { + return this.sidebarTaskItem(title).and(this.sidebar.locator('[aria-current="true"]')); + } + async openSidebarTaskContextMenu(title: string): Promise { const taskRow = this.sidebarTaskItem(title).first(); await taskRow.waitFor({ state: "visible" }); diff --git a/apps/web/e2e/tests/session/agent-error-indicator.spec.ts b/apps/web/e2e/tests/session/agent-error-indicator.spec.ts index 5544481635..3f29ea3de9 100644 --- a/apps/web/e2e/tests/session/agent-error-indicator.spec.ts +++ b/apps/web/e2e/tests/session/agent-error-indicator.spec.ts @@ -68,9 +68,27 @@ test.describe("Task agent error indicator", () => { await expect(testPage.getByRole("tooltip")).toContainText(ERROR_MESSAGE); // Dismissing the chat notice should also clear the sidebar icon. Both - // surfaces share the dismissal state through the UI store. + // surfaces share the dismissal state through the UI store and the server + // persists the dismissal for other browsers. await notice.getByRole("button", { name: "Hide previous agent error" }).click(); await expect(notice).toBeHidden(); await expect(errorIcon).toBeHidden(); + + await expect + .poll(async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + const sessionMeta = sessions.find((item) => item.id === task.session_id)?.metadata; + const error = sessionMeta?.last_agent_error as { dismissed_at?: string } | undefined; + return error?.dismissed_at ?? ""; + }) + .not.toBe(""); + + await testPage.evaluate(() => window.localStorage.removeItem("kandev.dismissedAgentErrors")); + await testPage.reload(); + await session.waitForLoad(); + await expect(testPage.getByTestId("last-agent-error-notice")).toBeHidden(); + await expect( + session.activeSidebarTaskItem(taskTitle).getByTestId("task-agent-error-icon"), + ).toBeHidden(); }); }); diff --git a/apps/web/hooks/use-processed-messages.test.ts b/apps/web/hooks/use-processed-messages.test.ts index b69b45ddd1..824c5dcfb2 100644 --- a/apps/web/hooks/use-processed-messages.test.ts +++ b/apps/web/hooks/use-processed-messages.test.ts @@ -10,6 +10,7 @@ import { buildGroupedRenderItems, collapseTodoSnapshotsPerTurn, deduplicateAgentBootResumes, + insertLastAgentErrorItem, isAgentBootResumeMessage, isSetupScriptMessage, messageListMapsEqual, @@ -262,6 +263,57 @@ describe("buildGroupedRenderItems prepare progress placement", () => { }); }); +describe("insertLastAgentErrorItem", () => { + it("inserts the notice after the nearest message before the error timestamp", () => { + const before = { + ...makeMessage("before", "message", undefined, "before"), + created_at: "2026-06-14T10:00:00Z", + }; + const after = { + ...makeMessage("after", "message", undefined, "after"), + created_at: "2026-06-14T10:10:00Z", + }; + const items = buildGroupedRenderItems([before, after], "s1", { + canAnchorPrepareProgress: false, + }); + + const result = insertLastAgentErrorItem(items, "s1", { + message: "peer disconnected before response", + occurredAt: "2026-06-14T10:05:00Z", + }); + + expect(result.map((item) => item.type)).toEqual(["message", "agent_error_notice", "message"]); + }); + + it("uses the notice as the only item when there are no messages", () => { + const result = insertLastAgentErrorItem([], "s1", { + message: "peer disconnected before response", + occurredAt: "2026-06-14T10:05:00Z", + }); + + expect(result).toEqual([ + expect.objectContaining({ + type: "agent_error_notice", + sessionId: "s1", + }), + ]); + }); + + it("appends the notice when existing items have no usable timestamps", () => { + const untimed = makeMessage("untimed", "message", undefined, "untimed"); + const items = buildGroupedRenderItems([untimed], "s1", { + canAnchorPrepareProgress: false, + }); + + const result = insertLastAgentErrorItem(items, "s1", { + message: "peer disconnected before response", + occurredAt: "2026-06-14T10:05:00Z", + }); + + expect(result.map((item) => item.type)).toEqual(["message", "agent_error_notice"]); + }); +}); + describe("messageMapsEqualByIdentity", () => { it("treats maps with the same keys and identical Message refs as equal", () => { const a = makeMessage("perm-1", "permission_request"); diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts index e34e31ffa3..d6dc251525 100644 --- a/apps/web/hooks/use-processed-messages.ts +++ b/apps/web/hooks/use-processed-messages.ts @@ -12,6 +12,7 @@ import { findPendingClarificationGroup, } from "@/lib/utils/pending-clarification"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import type { LastAgentError } from "@/lib/session-last-agent-error"; const debug = createDebugLogger("messages:process"); @@ -103,7 +104,18 @@ export type PrepareProgressItem = { sessionId: string; }; -export type RenderItem = { type: "message"; message: Message } | TurnGroup | PrepareProgressItem; +export type AgentErrorNoticeItem = { + type: "agent_error_notice"; + id: string; + sessionId: string; + error: LastAgentError; +}; + +export type RenderItem = + | { type: "message"; message: Message } + | TurnGroup + | PrepareProgressItem + | AgentErrorNoticeItem; export type GroupedRenderOptions = { canAnchorPrepareProgress?: boolean; @@ -111,6 +123,7 @@ export type GroupedRenderOptions = { export type ProcessedMessagesOptions = { hasOlderMessages?: boolean; + lastAgentError?: LastAgentError | null; }; function buildToolCallIds(messages: Message[]): Set { @@ -204,6 +217,26 @@ function renderItemKey(item: RenderItem): string { return item.type === "message" ? `m:${item.message.id}` : item.id; } +function agentErrorNoticeItemsEqual(a: AgentErrorNoticeItem, b: AgentErrorNoticeItem): boolean { + return ( + a.id === b.id && + a.sessionId === b.sessionId && + a.error.message === b.error.message && + a.error.occurredAt === b.error.occurredAt && + a.error.agentExecutionId === b.error.agentExecutionId + ); +} + +function turnGroupsEqual(a: TurnGroup, b: TurnGroup): boolean { + if (a.id !== b.id || a.turnId !== b.turnId || a.messages.length !== b.messages.length) { + return false; + } + for (let i = 0; i < a.messages.length; i++) { + if (a.messages[i] !== b.messages[i]) return false; + } + return true; +} + /** Content-equal when the wrapper would render identically. Turn groups compare * their messages by positional identity so a streaming token in the active turn * yields a fresh wrapper while quiescent earlier turns stay equal. */ @@ -211,14 +244,11 @@ function renderItemsEqual(a: RenderItem, b: RenderItem): boolean { if (a.type !== b.type) return false; if (a.type === "message" && b.type === "message") return a.message === b.message; if (a.type === "prepare_progress" && b.type === "prepare_progress") return a.id === b.id; + if (a.type === "agent_error_notice" && b.type === "agent_error_notice") { + return agentErrorNoticeItemsEqual(a, b); + } if (a.type === "turn_group" && b.type === "turn_group") { - if (a.id !== b.id || a.turnId !== b.turnId || a.messages.length !== b.messages.length) { - return false; - } - for (let i = 0; i < a.messages.length; i++) { - if (a.messages[i] !== b.messages[i]) return false; - } - return true; + return turnGroupsEqual(a, b); } return false; } @@ -467,13 +497,57 @@ function buildGroupedItemsForHook(args: { allSessionMessages: Message[]; resolvedSessionId: string | null; hasOlderMessages: boolean; + lastAgentError?: LastAgentError | null; }): RenderItem[] { - return buildGroupedRenderItems(args.regularMessages, args.resolvedSessionId, { - canAnchorPrepareProgress: canAnchorPrepareProgress( - args.allSessionMessages, - args.hasOlderMessages, - ), - }); + return insertLastAgentErrorItem( + buildGroupedRenderItems(args.regularMessages, args.resolvedSessionId, { + canAnchorPrepareProgress: canAnchorPrepareProgress( + args.allSessionMessages, + args.hasOlderMessages, + ), + }), + args.resolvedSessionId, + args.lastAgentError, + ); +} + +function itemCreatedAt(item: RenderItem): string | undefined { + if (item.type === "message") return item.message.created_at; + if (item.type === "turn_group") return item.messages[item.messages.length - 1]?.created_at; + return undefined; +} + +function insertionIndexForAgentError(items: RenderItem[], occurredAt?: string): number { + if (items.length === 0) return 0; + const errorTime = occurredAt ? Date.parse(occurredAt) : Number.NaN; + if (Number.isNaN(errorTime)) return items.length; + let insertAt = 0; + let sawTimestamp = false; + for (let i = 0; i < items.length; i++) { + const createdAt = itemCreatedAt(items[i]); + if (!createdAt) continue; + const itemTime = Date.parse(createdAt); + if (Number.isNaN(itemTime)) continue; + sawTimestamp = true; + if (itemTime <= errorTime) insertAt = i + 1; + } + return sawTimestamp ? insertAt : items.length; +} + +export function insertLastAgentErrorItem( + items: RenderItem[], + resolvedSessionId: string | null, + error?: LastAgentError | null, +): RenderItem[] { + if (!resolvedSessionId || !error) return items; + const notice: AgentErrorNoticeItem = { + type: "agent_error_notice", + id: `last-agent-error-${resolvedSessionId}-${error.occurredAt ?? "unknown"}`, + sessionId: resolvedSessionId, + error, + }; + const insertAt = insertionIndexForAgentError(items, error.occurredAt); + return [...items.slice(0, insertAt), notice, ...items.slice(insertAt)]; } function buildTodoItems(visibleMessages: Message[]) { @@ -568,8 +642,15 @@ export function useProcessedMessages( allSessionMessages: messages, resolvedSessionId, hasOlderMessages: options.hasOlderMessages ?? false, + lastAgentError: options.lastAgentError, }); - }, [regularMessages, resolvedSessionId, messages, options.hasOlderMessages]); + }, [ + regularMessages, + resolvedSessionId, + messages, + options.hasOlderMessages, + options.lastAgentError, + ]); const groupedItems = useStableGroupedItems(rawGroupedItems); useDebugProcessedPipeline({ diff --git a/apps/web/lib/api/domains/session-api.ts b/apps/web/lib/api/domains/session-api.ts index 45ff7a3c3f..ad0a92a01b 100644 --- a/apps/web/lib/api/domains/session-api.ts +++ b/apps/web/lib/api/domains/session-api.ts @@ -45,6 +45,20 @@ export async function fetchTaskSession(taskSessionId: string, options?: ApiReque return fetchJson(`/api/v1/task-sessions/${taskSessionId}`, options); } +export async function dismissLastAgentError( + taskSessionId: string, + stamp: string, + options?: ApiRequestOptions, +) { + return fetchJson( + `/api/v1/task-sessions/${taskSessionId}/last-agent-error/dismiss`, + { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ stamp }) }, + }, + ); +} + export async function listTaskSessionMessages( taskSessionId: string, params?: { limit?: number; before?: string; after?: string; sort?: "asc" | "desc" }, diff --git a/apps/web/lib/session-last-agent-error.test.ts b/apps/web/lib/session-last-agent-error.test.ts index 90004e385d..31d52b03ae 100644 --- a/apps/web/lib/session-last-agent-error.test.ts +++ b/apps/web/lib/session-last-agent-error.test.ts @@ -41,6 +41,30 @@ describe("readLastAgentError", () => { agentExecutionId: AGENT_EXECUTION_ID, }); }); + + it("returns null when the server has marked the error dismissed", () => { + expect( + readLastAgentError({ + last_agent_error: { + message: AGENT_ERROR_MESSAGE, + occurred_at: OCCURRED_AT, + dismissed_at: "2026-06-14T12:05:00Z", + }, + }), + ).toBeNull(); + }); + + it("returns null when dismissal is provided as camelCase dismissedAt", () => { + expect( + readLastAgentError({ + last_agent_error: { + message: AGENT_ERROR_MESSAGE, + occurredAt: OCCURRED_AT, + dismissedAt: "2026-06-14T12:05:00Z", + }, + }), + ).toBeNull(); + }); }); describe("lastAgentErrorStamp", () => { diff --git a/apps/web/lib/session-last-agent-error.ts b/apps/web/lib/session-last-agent-error.ts index 10fd21634b..ab20aebb1b 100644 --- a/apps/web/lib/session-last-agent-error.ts +++ b/apps/web/lib/session-last-agent-error.ts @@ -4,6 +4,7 @@ export type LastAgentError = { message: string; occurredAt?: string; agentExecutionId?: string; + dismissedAt?: string; }; // --- Dismissed agent errors (localStorage, global) --- @@ -35,6 +36,9 @@ export function readLastAgentError(metadata: Record | null | un const record = raw as Record; const message = typeof record.message === "string" ? record.message : ""; if (!message) return null; + const dismissedAt = + readOptionalString(record.dismissed_at) ?? readOptionalString(record.dismissedAt); + if (dismissedAt) return null; return { message, occurredAt: readOptionalString(record.occurred_at) ?? readOptionalString(record.occurredAt), diff --git a/apps/web/lib/task-agent-error.test.ts b/apps/web/lib/task-agent-error.test.ts index a1acfd277f..d069ab02de 100644 --- a/apps/web/lib/task-agent-error.test.ts +++ b/apps/web/lib/task-agent-error.test.ts @@ -97,6 +97,19 @@ describe("agentErrorMessageForTask", () => { ).toBeNull(); }); + it("hides the error when the session metadata is server-dismissed", () => { + const primary = primarySession({ + metadata: { + last_agent_error: { + message: PRIMARY_ERROR, + occurred_at: ERROR_OCCURRED_AT, + dismissed_at: "2026-06-14T10:05:00Z", + }, + }, + }); + expect(agentErrorMessageForTask(PRIMARY_TASK, { primary }, { "task-1": [primary] })).toBeNull(); + }); + it("keeps the error when only an older stamp is dismissed", () => { const primary = primarySession(); expect(