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(