fix: persist agent error dismissal (#1397)
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export class ApiClient {
|
||||
worktree_path?: string;
|
||||
worktree_branch?: string;
|
||||
error_message?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user