fix: persist agent error dismissal (#1397)

This commit is contained in:
Carlos Florêncio
2026-06-16 22:27:28 +01:00
committed by GitHub
parent e930061fe4
commit 327f7d4ec0
23 changed files with 494 additions and 43 deletions
@@ -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
}
@@ -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)
@@ -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 == "" {
+36 -3
View File
@@ -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.
@@ -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)
}
@@ -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
@@ -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
@@ -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)
@@ -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()
@@ -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({
);
})}
<LastAgentErrorNotice sessionId={sessionId} />
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
{(footerActionMessages ?? []).map((msg: Message) => (
<MessageRenderer key={msg.id} comment={msg} isTaskDescription={false} />
@@ -21,6 +21,7 @@ const mockStoreState = vi.hoisted(() => ({
},
dismissedAgentErrors: {} as Record<string, string>,
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", () => ({
<div data-testid="session-panel-content">{children}</div>
),
}));
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(
<VirtuosoMessageList
items={[]}
messages={[]}
<MessageItem
item={{
type: "agent_error_notice",
id: "last-agent-error-s1-2026-06-14T12:00:00Z",
sessionId: "s1",
error: {
message: "agent process exited",
occurredAt: "2026-06-14T12:00:00Z",
},
}}
sessionId="s1"
permissionsByToolCallId={perm}
childrenByParentToolCallId={kids}
sessionId="s1"
messagesLoading={false}
isWorking={false}
sessionState="WAITING_FOR_INPUT"
taskId="t1"
isLastGroup={false}
isTurnActive={false}
onScrollToMessage={noop}
/>,
);
@@ -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 <PrepareProgress sessionId={item.sessionId} />;
}
if (item.type === "agent_error_notice") {
return <LastAgentErrorNotice sessionId={item.sessionId} error={item.error} />;
}
if (item.type === "turn_group") {
return (
<TurnGroupMessage
@@ -11,7 +11,6 @@ import { MessageRenderer } from "@/components/task/chat/message-renderer";
import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
import {
type MessageListProps,
LastAgentErrorNotice,
MessageListStatus,
MessageItem,
getItemKey,
@@ -421,7 +420,6 @@ function useVirtuosoHeaderFooter(args: HeaderFooterArgs) {
const Footer = useCallback(
() => (
<>
<LastAgentErrorNotice sessionId={sessionId} />
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
{footerActions.map((msg) => (
<MessageRenderer key={msg.id} comment={msg} isTaskDescription={false} />
@@ -492,7 +490,6 @@ export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: Mess
messagesCount={messages.length}
onLoadMore={loadMore}
/>
<LastAgentErrorNotice sessionId={sessionId} />
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
{footerActions.map((msg) => (
<MessageRenderer key={msg.id} comment={msg} isTaskDescription={false} />
@@ -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,
+1
View File
@@ -1124,6 +1124,7 @@ export class ApiClient {
worktree_path?: string;
worktree_branch?: string;
error_message?: string;
metadata?: Record<string, unknown>;
}>;
total: number;
}> {
+4
View File
@@ -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<void> {
const taskRow = this.sidebarTaskItem(title).first();
await taskRow.waitFor({ state: "visible" });
@@ -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();
});
});
@@ -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");
+96 -15
View File
@@ -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<string> {
@@ -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({
+14
View File
@@ -45,6 +45,20 @@ export async function fetchTaskSession(taskSessionId: string, options?: ApiReque
return fetchJson<TaskSessionResponse>(`/api/v1/task-sessions/${taskSessionId}`, options);
}
export async function dismissLastAgentError(
taskSessionId: string,
stamp: string,
options?: ApiRequestOptions,
) {
return fetchJson<TaskSessionResponse>(
`/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" },
@@ -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", () => {
+4
View File
@@ -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<string, unknown> | null | un
const record = raw as Record<string, unknown>;
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),
+13
View File
@@ -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(