fix: preserve recoverable agent errors (#1368)
This commit is contained in:
@@ -11,6 +11,7 @@ package testharness
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
@@ -169,12 +170,14 @@ func seedTaskHandler(repo *sqliterepo.Repository, log *logger.Logger) gin.Handle
|
||||
}
|
||||
|
||||
type seedTaskSessionRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
State string `json:"state"`
|
||||
AgentProfileID string `json:"agent_profile_id,omitempty"`
|
||||
StartedAt *string `json:"started_at,omitempty"`
|
||||
CompletedAt *string `json:"completed_at,omitempty"`
|
||||
CommandCount int `json:"command_count,omitempty"`
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
State string `json:"state"`
|
||||
AgentProfileID string `json:"agent_profile_id,omitempty"`
|
||||
StartedAt *string `json:"started_at,omitempty"`
|
||||
CompletedAt *string `json:"completed_at,omitempty"`
|
||||
CommandCount int `json:"command_count,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type seedTaskSessionResponse struct {
|
||||
@@ -207,16 +210,14 @@ func seedTaskSessionHandler(repo *sqliterepo.Repository, eventBus bus.EventBus,
|
||||
// state in place. Office sessions are unique per (task, agent) per the
|
||||
// schema's partial unique index, and tests routinely flip the same
|
||||
// pair RUNNING → IDLE (and back) to exercise reactive UI.
|
||||
if existing, _ := repo.GetTaskSessionByTaskAndAgent(ctx, req.TaskID, req.AgentProfileID); existing != nil {
|
||||
existing.State = session.State
|
||||
existing.CompletedAt = session.CompletedAt
|
||||
existing.UpdatedAt = time.Now().UTC()
|
||||
if err := repo.UpdateTaskSession(ctx, existing); err != nil {
|
||||
existing := getExistingSeedSession(ctx, repo, &req)
|
||||
if existing != nil {
|
||||
session, err = updateSeededSession(ctx, repo, existing, session, req.TaskID)
|
||||
if err != nil {
|
||||
log.Error("test harness: update session failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
c.JSON(statusForSeedSessionUpdateError(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
session = existing
|
||||
} else if err := repo.CreateTaskSession(ctx, session); err != nil {
|
||||
log.Error("test harness: create session failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -236,6 +237,56 @@ func seedTaskSessionHandler(repo *sqliterepo.Repository, eventBus bus.EventBus,
|
||||
}
|
||||
}
|
||||
|
||||
func updateSeededSession(
|
||||
ctx context.Context,
|
||||
repo *sqliterepo.Repository,
|
||||
existing *models.TaskSession,
|
||||
session *models.TaskSession,
|
||||
taskID string,
|
||||
) (*models.TaskSession, error) {
|
||||
if existing.TaskID != taskID {
|
||||
return nil, seedSessionUpdateError{message: "session does not belong to task: " + existing.ID}
|
||||
}
|
||||
existing.State = session.State
|
||||
existing.CompletedAt = session.CompletedAt
|
||||
existing.UpdatedAt = time.Now().UTC()
|
||||
if existing.Metadata == nil {
|
||||
existing.Metadata = map[string]interface{}{}
|
||||
}
|
||||
for key, value := range session.Metadata {
|
||||
existing.Metadata[key] = value
|
||||
}
|
||||
if err := repo.UpdateTaskSessionWithMetadata(ctx, existing, existing.Metadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
type seedSessionUpdateError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (err seedSessionUpdateError) Error() string {
|
||||
return err.message
|
||||
}
|
||||
|
||||
func statusForSeedSessionUpdateError(err error) int {
|
||||
var updateErr seedSessionUpdateError
|
||||
if errors.As(err, &updateErr) {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func getExistingSeedSession(ctx context.Context, repo *sqliterepo.Repository, req *seedTaskSessionRequest) *models.TaskSession {
|
||||
if req.SessionID != "" {
|
||||
existing, _ := repo.GetTaskSession(ctx, req.SessionID)
|
||||
return existing
|
||||
}
|
||||
existing, _ := repo.GetTaskSessionByTaskAndAgent(ctx, req.TaskID, req.AgentProfileID)
|
||||
return existing
|
||||
}
|
||||
|
||||
type seedWorkflowRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
@@ -309,16 +360,23 @@ func buildSeededSession(req *seedTaskSessionRequest) (*models.TaskSession, error
|
||||
completedAt = &ts
|
||||
}
|
||||
metadata := map[string]interface{}{"seeded_by_e2e_mock": true}
|
||||
for key, value := range req.Metadata {
|
||||
metadata[key] = value
|
||||
}
|
||||
if req.AgentProfileID != "" {
|
||||
metadata["agent_profile_id"] = req.AgentProfileID
|
||||
}
|
||||
sessionID := req.SessionID
|
||||
if sessionID == "" {
|
||||
sessionID = uuid.New().String()
|
||||
}
|
||||
// Leave AgentProfileID empty when the caller didn't supply one — that
|
||||
// makes the seeded row a non-office (kanban / quick-chat) session, so
|
||||
// it renders inline in the task chat timeline rather than collapsing
|
||||
// into a per-agent sibling tab. Tests that want office (per-agent)
|
||||
// behaviour pass the agent profile id explicitly.
|
||||
return &models.TaskSession{
|
||||
ID: uuid.New().String(),
|
||||
ID: sessionID,
|
||||
TaskID: req.TaskID,
|
||||
AgentProfileID: req.AgentProfileID,
|
||||
State: models.TaskSessionState(req.State),
|
||||
|
||||
@@ -137,6 +137,41 @@ func TestSeedTaskSessionHappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedTaskSessionUsesRequestedSessionID(t *testing.T) {
|
||||
repo, sqlxDB := newTestRepo(t)
|
||||
taskID := uuid.New().String()
|
||||
sessionID := uuid.New().String()
|
||||
seedTask(t, sqlxDB, taskID)
|
||||
r := newRouter(t, repo, nil)
|
||||
|
||||
body := mustJSON(t, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"session_id": sessionID,
|
||||
"state": "RUNNING",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/_test/task-sessions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp seedTaskSessionResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.SessionID != sessionID {
|
||||
t.Fatalf("session_id = %q, want requested %q", resp.SessionID, sessionID)
|
||||
}
|
||||
session, err := repo.GetTaskSession(context.Background(), sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("get requested session: %v", err)
|
||||
}
|
||||
if session.TaskID != taskID {
|
||||
t.Fatalf("task_id = %q, want %q", session.TaskID, taskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedTaskSessionTerminalRequiresCompletedAt(t *testing.T) {
|
||||
repo, sqlxDB := newTestRepo(t)
|
||||
taskID := uuid.New().String()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -691,6 +692,7 @@ func (s *Service) handleRecoverableFailure(ctx context.Context, data watcher.Age
|
||||
|
||||
// Complete the current turn.
|
||||
s.completeTurnForSession(ctx, data.SessionID)
|
||||
s.persistLastAgentError(ctx, data)
|
||||
|
||||
// Create a status message with recovery action metadata.
|
||||
// Skipped for office sessions: the office task page renders its
|
||||
@@ -724,6 +726,27 @@ func (s *Service) handleRecoverableFailure(ctx context.Context, data watcher.Age
|
||||
go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
|
||||
}
|
||||
|
||||
func (s *Service) persistLastAgentError(ctx context.Context, data watcher.AgentEventData) {
|
||||
errMsg := data.ErrorMessage
|
||||
if errMsg == "" {
|
||||
errMsg = "agent failed"
|
||||
}
|
||||
// Keep this metadata until the user dismisses the UI notice locally or a
|
||||
// later recoverable failure replaces it. A successful turn should not erase
|
||||
// the investigation breadcrumb that explains why the task was marked REVIEW.
|
||||
lastErr := models.LastAgentError{
|
||||
Message: errMsg,
|
||||
OccurredAt: time.Now().UTC(),
|
||||
AgentExecutionID: data.AgentExecutionID,
|
||||
}
|
||||
if err := s.repo.SetSessionMetadataKey(ctx, data.SessionID, models.SessionMetaKeyLastAgentError, lastErr); err != nil {
|
||||
s.logger.Warn("failed to persist last agent error",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// createRecoveryStatusMessage builds and persists the ActionMessage shown
|
||||
// in the kanban chat surface after a recoverable agent failure. Must only
|
||||
// be called for non-office sessions (office sessions render their own error UI).
|
||||
|
||||
@@ -1605,6 +1605,14 @@ func TestHandleAgentFailed_RecoverableWithSession(t *testing.T) {
|
||||
if session.State != models.TaskSessionStateWaitingForInput {
|
||||
t.Errorf("expected recoverable state %q, got %q", models.TaskSessionStateWaitingForInput, session.State)
|
||||
}
|
||||
lastErrRaw := session.Metadata[models.SessionMetaKeyLastAgentError]
|
||||
lastErr, ok := lastErrRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected last agent error metadata map, got %#v", lastErrRaw)
|
||||
}
|
||||
if got := lastErr["message"]; got != "agent process exited" {
|
||||
t.Fatalf("expected last agent error message to persist, got %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("routes to resume failure when resume token exists and init not completed", func(t *testing.T) {
|
||||
|
||||
@@ -99,6 +99,17 @@ type SessionRuntimeConfig struct {
|
||||
// other step change.
|
||||
const SessionMetaKeyPendingStepCompletion = "pending_step_completion_signal"
|
||||
|
||||
// SessionMetaKeyLastAgentError stores the last recoverable agent runtime
|
||||
// failure for UI surfaces that need to keep the error visible after auto-resume.
|
||||
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"`
|
||||
}
|
||||
|
||||
// PendingStepCompletionSignal source values.
|
||||
const (
|
||||
StepCompletionSourceAgent = "agent"
|
||||
|
||||
@@ -17,6 +17,10 @@ import (
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
type taskSessionExecutor interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
}
|
||||
|
||||
// Turn operations
|
||||
|
||||
// CreateTurn creates a new turn
|
||||
@@ -436,7 +440,40 @@ func (r *Repository) ListNonTerminalSessionsByAgentInstance(ctx context.Context,
|
||||
// Use UpdateSessionMetadata for metadata changes.
|
||||
func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.TaskSession) error {
|
||||
session.UpdatedAt = time.Now().UTC()
|
||||
return r.updateTaskSession(ctx, r.db, session)
|
||||
}
|
||||
|
||||
// UpdateTaskSessionWithMetadata updates the session row and metadata column in
|
||||
// one transaction so callers cannot observe a partially-applied update.
|
||||
func (r *Repository) UpdateTaskSessionWithMetadata(
|
||||
ctx context.Context,
|
||||
session *models.TaskSession,
|
||||
metadata map[string]interface{},
|
||||
) error {
|
||||
session.UpdatedAt = time.Now().UTC()
|
||||
metadataJSON, err := marshalSessionMetadata(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := r.updateTaskSession(ctx, tx, session); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.updateSessionMetadataJSON(ctx, tx, session.ID, metadataJSON, session.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *Repository) updateTaskSession(
|
||||
ctx context.Context,
|
||||
exec taskSessionExecutor,
|
||||
session *models.TaskSession,
|
||||
) error {
|
||||
agentProfileSnapshotJSON, err := json.Marshal(session.AgentProfileSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize agent profile snapshot: %w", err)
|
||||
@@ -465,7 +502,7 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task
|
||||
if session.AgentProfileID != "" {
|
||||
agentProfileID = session.AgentProfileID
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
result, err := exec.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE task_sessions SET
|
||||
agent_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?,
|
||||
repository_id = ?, base_branch = ?, base_commit_sha = ?, workspace_path = ?,
|
||||
@@ -537,14 +574,32 @@ func (r *Repository) CancelActiveTaskSessionsByTaskID(ctx context.Context, taskI
|
||||
// UpdateSessionMetadata updates only the metadata column of a session,
|
||||
// avoiding a full-row overwrite that could clobber concurrent field updates.
|
||||
func (r *Repository) UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error {
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
metadataJSON, err := marshalSessionMetadata(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize metadata: %w", err)
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
return r.updateSessionMetadataJSON(ctx, r.db, sessionID, metadataJSON, now)
|
||||
}
|
||||
|
||||
func marshalSessionMetadata(metadata map[string]interface{}) (string, error) {
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize metadata: %w", err)
|
||||
}
|
||||
return string(metadataJSON), nil
|
||||
}
|
||||
|
||||
func (r *Repository) updateSessionMetadataJSON(
|
||||
ctx context.Context,
|
||||
exec taskSessionExecutor,
|
||||
sessionID string,
|
||||
metadataJSON string,
|
||||
updatedAt time.Time,
|
||||
) error {
|
||||
result, err := exec.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE task_sessions SET metadata = ?, updated_at = ? WHERE id = ?
|
||||
`), string(metadataJSON), now, sessionID)
|
||||
`), metadataJSON, updatedAt, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
func newRepoForSessionTests(t *testing.T) *Repository {
|
||||
@@ -480,6 +481,36 @@ func sessionState(t *testing.T, repo *Repository, sessionID string) string {
|
||||
return state
|
||||
}
|
||||
|
||||
func TestUpdateTaskSessionWithMetadataRejectsInvalidMetadataBeforeStateWrite(t *testing.T) {
|
||||
repo := newRepoForSessionTests(t)
|
||||
ctx := context.Background()
|
||||
|
||||
seedForMsgTest(t, repo, "task-atomic", "sess-atomic", "turn-atomic")
|
||||
if err := repo.UpdateSessionMetadata(ctx, "sess-atomic", map[string]interface{}{"keep": "yes"}); err != nil {
|
||||
t.Fatalf("seed metadata: %v", err)
|
||||
}
|
||||
session, err := repo.GetTaskSession(ctx, "sess-atomic")
|
||||
if err != nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
session.State = models.TaskSessionStateFailed
|
||||
|
||||
err = repo.UpdateTaskSessionWithMetadata(ctx, session, map[string]interface{}{"bad": func() {}})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid metadata error")
|
||||
}
|
||||
if got := sessionState(t, repo, "sess-atomic"); got == string(models.TaskSessionStateFailed) {
|
||||
t.Fatalf("state was partially updated to %q", got)
|
||||
}
|
||||
got, err := repo.GetTaskSession(ctx, "sess-atomic")
|
||||
if err != nil {
|
||||
t.Fatalf("get session after failed update: %v", err)
|
||||
}
|
||||
if got.Metadata["keep"] != "yes" {
|
||||
t.Fatalf("metadata keep = %v, want yes", got.Metadata["keep"])
|
||||
}
|
||||
}
|
||||
|
||||
func sessionCancellationMetadata(t *testing.T, repo *Repository, sessionID string) (string, sql.NullTime, time.Time) {
|
||||
t.Helper()
|
||||
var errorMessage string
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MessageRenderer } from "@/components/task/chat/message-renderer";
|
||||
import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
|
||||
import {
|
||||
type MessageListProps,
|
||||
LastAgentErrorNotice,
|
||||
MessageListStatus,
|
||||
MessageItem,
|
||||
getItemKey,
|
||||
@@ -250,6 +251,7 @@ 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} />
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RenderItem } from "@/hooks/use-processed-messages";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
|
||||
const rendererSpy = vi.fn();
|
||||
const mockStoreState = vi.hoisted(() => ({
|
||||
taskSessions: {
|
||||
items: {
|
||||
s1: {
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: "agent process exited",
|
||||
occurred_at: "2026-06-14T12:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task/chat/message-renderer", () => ({
|
||||
MessageRenderer: (props: { onOpenFile?: unknown }) => {
|
||||
@@ -18,8 +33,27 @@ vi.mock("@/components/task/chat/messages/turn-group-message", () => ({
|
||||
vi.mock("@/components/session/prepare-progress", () => ({
|
||||
PrepareProgress: () => <div data-testid="prepare" />,
|
||||
}));
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState),
|
||||
}));
|
||||
vi.mock("@/hooks/use-lazy-load-messages", () => ({
|
||||
useLazyLoadMessages: () => ({
|
||||
loadMore: async () => 0,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/task/chat/messages/agent-status", () => ({
|
||||
AgentStatus: () => <div data-testid="agent-status" />,
|
||||
}));
|
||||
vi.mock("@kandev/ui/pannel-session", () => ({
|
||||
SessionPanelContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="session-panel-content">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MessageItem } from "./message-list-shared";
|
||||
import { VirtuosoMessageList } from "./message-list-virtuoso";
|
||||
|
||||
const item: RenderItem = { type: "message", message: { id: "m1" } as Message };
|
||||
const noop = () => {};
|
||||
@@ -73,3 +107,23 @@ describe("MessageItem memo boundary", () => {
|
||||
expect(rendererSpy).toHaveBeenCalledTimes(2); // fresh callback ref breaks memo
|
||||
});
|
||||
});
|
||||
|
||||
describe("VirtuosoMessageList empty state", () => {
|
||||
it("shows retained agent errors even when there are no messages", () => {
|
||||
render(
|
||||
<VirtuosoMessageList
|
||||
items={[]}
|
||||
messages={[]}
|
||||
permissionsByToolCallId={perm}
|
||||
childrenByParentToolCallId={kids}
|
||||
sessionId="s1"
|
||||
messagesLoading={false}
|
||||
isWorking={false}
|
||||
sessionState="WAITING_FOR_INPUT"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("last-agent-error-notice").getAttribute("role")).toBe("alert");
|
||||
expect(screen.queryByText("agent process exited")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { IconAlertCircle, IconX } from "@tabler/icons-react";
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
import type { Message, TaskSessionState } from "@/lib/types/http";
|
||||
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 { lastAgentErrorDismissKey, readLastAgentError } from "@/lib/session-last-agent-error";
|
||||
|
||||
export type MessageListProps = {
|
||||
items: RenderItem[];
|
||||
@@ -42,6 +45,53 @@ export function getLastTurnGroupId(items: RenderItem[]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LastAgentErrorNotice({ sessionId }: { sessionId: string | null }) {
|
||||
const metadata = useAppStore((state) =>
|
||||
sessionId ? state.taskSessions.items[sessionId]?.metadata : null,
|
||||
);
|
||||
const error = readLastAgentError(metadata);
|
||||
const dismissKey = sessionId && error ? lastAgentErrorDismissKey(sessionId, error) : "";
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(() =>
|
||||
dismissKey && globalThis.localStorage?.getItem(dismissKey) === "true" ? dismissKey : null,
|
||||
);
|
||||
const isDismissed =
|
||||
dismissedKey === dismissKey || globalThis.localStorage?.getItem(dismissKey) === "true";
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (!dismissKey) return;
|
||||
globalThis.localStorage?.setItem(dismissKey, "true");
|
||||
setDismissedKey(dismissKey);
|
||||
}, [dismissKey]);
|
||||
|
||||
if (!error || isDismissed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="last-agent-error-notice"
|
||||
className="mb-3 rounded-md border border-destructive/25 bg-destructive/10 text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-2 px-3 py-2">
|
||||
<IconAlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium">Previous agent error</div>
|
||||
<pre className="mt-1 max-h-40 overflow-y-auto whitespace-pre-wrap break-words text-[11px] leading-relaxed text-destructive/85">
|
||||
{error.message}
|
||||
</pre>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded hover:bg-destructive/10 cursor-pointer"
|
||||
aria-label="Hide previous agent error"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageListStatus({
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MessageRenderer } from "@/components/task/chat/message-renderer";
|
||||
import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
|
||||
import {
|
||||
type MessageListProps,
|
||||
LastAgentErrorNotice,
|
||||
MessageListStatus,
|
||||
MessageItem,
|
||||
getItemKey,
|
||||
@@ -420,6 +421,7 @@ 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} />
|
||||
@@ -490,6 +492,7 @@ 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} />
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
repositoryId as toRepositoryId,
|
||||
type TaskState,
|
||||
type TaskSession,
|
||||
type TaskSessionState,
|
||||
type Repository,
|
||||
type Task,
|
||||
@@ -26,6 +27,7 @@ import type { KanbanState } from "@/lib/state/slices";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
import { repositorySlug } from "@/lib/repository-slug";
|
||||
import { resolvePreferredSessionId } from "../task-select-helpers";
|
||||
import { agentErrorMessageForTask } from "@/lib/task-agent-error";
|
||||
|
||||
// Map workflow snapshot to kanban state on workspace switch.
|
||||
function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: string) {
|
||||
@@ -85,6 +87,7 @@ type SheetItemCtx = {
|
||||
repositoryPathsById: Map<string, string | undefined>;
|
||||
workflowNameById: Map<string, string>;
|
||||
stepTitleById: Map<string, string>;
|
||||
sessionsById: Record<string, TaskSession>;
|
||||
sessionsByTaskId: Parameters<typeof getSessionInfoForTask>[1];
|
||||
gitStatusByEnvId: Parameters<typeof getSessionInfoForTask>[2];
|
||||
envIdBySessionId: Parameters<typeof getSessionInfoForTask>[3];
|
||||
@@ -132,6 +135,7 @@ function toSheetItem(
|
||||
ctx.messagesBySession,
|
||||
task.primarySessionId,
|
||||
),
|
||||
agentErrorMessage: agentErrorMessageForTask(task, ctx.sessionsById, ctx.sessionsByTaskId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,6 +171,7 @@ export function useSheetData(workspaceId: string | null) {
|
||||
),
|
||||
workflowNameById: new Map(workflows.map((w) => [w.id, w.name])),
|
||||
stepTitleById: new Map(allSteps.map((s) => [s.id, s.title])),
|
||||
sessionsById,
|
||||
sessionsByTaskId,
|
||||
gitStatusByEnvId,
|
||||
envIdBySessionId,
|
||||
@@ -179,6 +184,7 @@ export function useSheetData(workspaceId: string | null) {
|
||||
allSteps,
|
||||
workflows,
|
||||
workspaceId,
|
||||
sessionsById,
|
||||
sessionsByTaskId,
|
||||
gitStatusByEnvId,
|
||||
envIdBySessionId,
|
||||
|
||||
@@ -3,11 +3,13 @@ import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { TaskItem } from "./task-item";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
|
||||
const REVIEW_ICON_TEST_ID = "task-state-review";
|
||||
const RUNNING_ICON_TEST_ID = "task-state-running";
|
||||
const WAITING_FOR_INPUT_ICON_TEST_ID = "task-state-waiting-for-input";
|
||||
const PENDING_PERMISSION_ICON_TEST_ID = "task-state-pending-permission";
|
||||
const AGENT_ERROR_ICON_TEST_ID = "task-agent-error-icon";
|
||||
const PREPARING_PHASE = "preparing";
|
||||
const PURPLE_SPINNER_CLASS = "text-purple-500";
|
||||
const SPIN_CLASS = "animate-spin";
|
||||
@@ -18,7 +20,9 @@ afterEach(() => cleanup());
|
||||
function renderTaskItem(props: Partial<ComponentProps<typeof TaskItem>> = {}) {
|
||||
return render(
|
||||
<StateProvider>
|
||||
<TaskItem title="Needs answer" state="REVIEW" {...props} />
|
||||
<TooltipProvider>
|
||||
<TaskItem title="Needs answer" state="REVIEW" {...props} />
|
||||
</TooltipProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
@@ -113,4 +117,13 @@ describe("TaskItem status icon", () => {
|
||||
expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).not.toBeNull();
|
||||
expect(screen.queryByTestId(WAITING_FOR_INPUT_ICON_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a separate agent error icon when the task has retained error details", () => {
|
||||
renderTaskItem({ agentErrorMessage: "peer disconnected before response" });
|
||||
|
||||
const errorIcon = screen.getByTestId(AGENT_ERROR_ICON_TEST_ID);
|
||||
expect(errorIcon).not.toBeNull();
|
||||
expect(errorIcon.classList.contains("cursor-help")).toBe(true);
|
||||
expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo } from "react";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconChevronDown,
|
||||
IconCircleCheck,
|
||||
IconCircleDashed,
|
||||
@@ -68,6 +69,7 @@ type TaskItemProps = {
|
||||
prInfo?: { number: number; state: string };
|
||||
issueInfo?: { url: string; number: number };
|
||||
isPinned?: boolean;
|
||||
agentErrorMessage?: string | null;
|
||||
};
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
@@ -266,6 +268,7 @@ function TaskItemContent({
|
||||
updatedAt,
|
||||
prInfo,
|
||||
issueInfo,
|
||||
agentErrorMessage,
|
||||
}: {
|
||||
title: string;
|
||||
taskId?: string;
|
||||
@@ -279,6 +282,7 @@ function TaskItemContent({
|
||||
updatedAt?: string;
|
||||
prInfo?: { number: number; state: string };
|
||||
issueInfo?: { url: string; number: number };
|
||||
agentErrorMessage?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
@@ -292,6 +296,7 @@ function TaskItemContent({
|
||||
)}
|
||||
<TaskPRIcon taskId={taskId} prInfo={prInfo} />
|
||||
{issueInfo && <IssueTaskIcon issueInfo={issueInfo} />}
|
||||
{agentErrorMessage && <TaskAgentErrorIcon message={agentErrorMessage} />}
|
||||
{isRemoteExecutor && (
|
||||
<RemoteCloudTooltip
|
||||
taskId={taskId ?? ""}
|
||||
@@ -317,6 +322,25 @@ function TaskItemContent({
|
||||
);
|
||||
}
|
||||
|
||||
function TaskAgentErrorIcon({ message }: { message: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid="task-agent-error-icon"
|
||||
className="inline-flex shrink-0 cursor-help text-destructive"
|
||||
aria-label="Task has an agent error"
|
||||
>
|
||||
<IconAlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[320px] whitespace-pre-wrap break-words">
|
||||
{message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const TaskItem = memo(function TaskItem({
|
||||
title,
|
||||
state,
|
||||
@@ -344,6 +368,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
prInfo,
|
||||
issueInfo,
|
||||
isPinned,
|
||||
agentErrorMessage,
|
||||
}: TaskItemProps) {
|
||||
const effectiveMenuOpen = menuOpen || isDeleting === true;
|
||||
const isInProgress = computeIsInProgress(state, sessionState);
|
||||
@@ -391,6 +416,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
updatedAt={updatedAt}
|
||||
prInfo={prInfo}
|
||||
issueInfo={issueInfo}
|
||||
agentErrorMessage={agentErrorMessage}
|
||||
/>
|
||||
{hasDiffStats ? (
|
||||
<div className="relative shrink-0 self-center flex items-center">
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
|
||||
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
|
||||
import { buildPendingFlags, readPendingFlags } from "./task-session-sidebar-aggregate";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { agentErrorMessageForTask } from "@/lib/task-agent-error";
|
||||
|
||||
/**
|
||||
* Stabilize a derived array of primary session IDs so the reference only
|
||||
@@ -85,6 +86,7 @@ function toPrInfo(pr: TaskPR | undefined): { number: number; state: string } | u
|
||||
|
||||
/** Map a kanban task to a sidebar item with session info and repository metadata. */
|
||||
type SidebarCtx = {
|
||||
sessionsById: Record<string, TaskSession>;
|
||||
sessionsByTaskId: Record<string, TaskSession[]>;
|
||||
gitStatusByEnvId: Record<string, GitStatusEntry>;
|
||||
envIdBySessionId: Record<string, string>;
|
||||
@@ -158,6 +160,7 @@ function toSidebarItem(
|
||||
isPRReview: task.isPRReview ?? false,
|
||||
isIssueWatch: task.isIssueWatch ?? false,
|
||||
issueInfo: toIssueInfo(task),
|
||||
agentErrorMessage: agentErrorMessageForTask(task, ctx.sessionsById, ctx.sessionsByTaskId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,6 +201,7 @@ function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): SidebarI
|
||||
isPRReview: false,
|
||||
isIssueWatch: false,
|
||||
issueInfo: undefined,
|
||||
agentErrorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -242,6 +246,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
const workflowNameById = new Map(workflows.map((w) => [w.id, w.name]));
|
||||
const stepTitleById = new Map(allSteps.map((s) => [s.id, s.title]));
|
||||
const mapCtx = {
|
||||
sessionsById,
|
||||
sessionsByTaskId,
|
||||
gitStatusByEnvId,
|
||||
envIdBySessionId,
|
||||
@@ -268,6 +273,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
workflows,
|
||||
workspaceId,
|
||||
sessionsByTaskId,
|
||||
sessionsById,
|
||||
gitStatusByEnvId,
|
||||
envIdBySessionId,
|
||||
taskPRsByTaskId,
|
||||
|
||||
@@ -42,6 +42,7 @@ export type TaskSwitcherItem = {
|
||||
isPRReview?: boolean;
|
||||
isIssueWatch?: boolean;
|
||||
issueInfo?: { url: string; number: number };
|
||||
agentErrorMessage?: string | null;
|
||||
};
|
||||
|
||||
type TaskSwitcherProps = {
|
||||
@@ -192,6 +193,7 @@ function TaskRow({
|
||||
repositories={task.repositories}
|
||||
prInfo={task.prInfo}
|
||||
issueInfo={task.issueInfo}
|
||||
agentErrorMessage={task.agentErrorMessage}
|
||||
isSubTask={isSubTask}
|
||||
depth={depth}
|
||||
subtaskCount={subtaskToggle?.subtaskCount}
|
||||
|
||||
@@ -749,20 +749,24 @@ export class ApiClient {
|
||||
taskId: string,
|
||||
opts: {
|
||||
state: TaskSessionState;
|
||||
sessionId?: string;
|
||||
agentProfileId?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
commandCount?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{ session_id: string }> {
|
||||
const body: Record<string, unknown> = {
|
||||
task_id: taskId,
|
||||
state: opts.state,
|
||||
};
|
||||
if (opts.sessionId !== undefined) body.session_id = opts.sessionId;
|
||||
if (opts.agentProfileId !== undefined) body.agent_profile_id = opts.agentProfileId;
|
||||
if (opts.startedAt !== undefined) body.started_at = opts.startedAt;
|
||||
if (opts.completedAt !== undefined) body.completed_at = opts.completedAt;
|
||||
if (opts.commandCount !== undefined) body.command_count = opts.commandCount;
|
||||
if (opts.metadata !== undefined) body.metadata = opts.metadata;
|
||||
return this.request("POST", "/api/v1/_test/task-sessions", body);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { waitForSessionDone } from "../../helpers/session";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const ERROR_MESSAGE = "peer disconnected before response";
|
||||
const ERROR_OCCURRED_AT = "2026-06-14T14:06:40Z";
|
||||
|
||||
test.describe("Task agent error indicator", () => {
|
||||
test("shows the last recoverable agent error in the sidebar and chat", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const taskTitle = `Recoverable Agent Error ${Date.now()}`;
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
taskTitle,
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
||||
await waitForSessionDone(apiClient, task.id, task.session_id, "Waiting for seeded session");
|
||||
|
||||
await apiClient.seedTaskSession(task.id, {
|
||||
state: "WAITING_FOR_INPUT",
|
||||
sessionId: task.session_id,
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: ERROR_MESSAGE,
|
||||
occurred_at: ERROR_OCCURRED_AT,
|
||||
agent_execution_id: "agent-exec-e2e",
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
const session = sessions.find((item) => item.id === task.session_id);
|
||||
const error = session?.metadata?.last_agent_error as { message?: string } | undefined;
|
||||
return error?.message ?? "";
|
||||
})
|
||||
.toBe(ERROR_MESSAGE);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
// .first() - desktop and mobile sidebars can both be mounted in this layout.
|
||||
const taskRow = session.sidebarTaskItem(taskTitle).first();
|
||||
const errorIcon = taskRow.getByTestId("task-agent-error-icon");
|
||||
await expect(errorIcon).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const notice = testPage.getByTestId("last-agent-error-notice");
|
||||
await expect(notice).toBeVisible({ timeout: 15_000 });
|
||||
await expect(notice).toContainText("Previous agent error");
|
||||
await expect(notice).toContainText(ERROR_MESSAGE);
|
||||
|
||||
await notice.getByRole("button", { name: "Hide previous agent error" }).click();
|
||||
await expect(notice).toBeHidden();
|
||||
|
||||
await errorIcon.hover();
|
||||
await expect(testPage.getByRole("tooltip")).toContainText(ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { lastAgentErrorDismissKey, readLastAgentError } from "./session-last-agent-error";
|
||||
|
||||
const AGENT_ERROR_MESSAGE = "agent process exited";
|
||||
const AGENT_EXECUTION_ID = "exec-1";
|
||||
const OCCURRED_AT = "2026-06-14T12:00:00Z";
|
||||
|
||||
describe("readLastAgentError", () => {
|
||||
it("reads snake_case metadata and keeps occurredAt optional", () => {
|
||||
expect(
|
||||
readLastAgentError({
|
||||
last_agent_error: {
|
||||
message: AGENT_ERROR_MESSAGE,
|
||||
agent_execution_id: AGENT_EXECUTION_ID,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
message: AGENT_ERROR_MESSAGE,
|
||||
agentExecutionId: AGENT_EXECUTION_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads camelCase metadata after a store round trip", () => {
|
||||
expect(
|
||||
readLastAgentError({
|
||||
last_agent_error: {
|
||||
message: AGENT_ERROR_MESSAGE,
|
||||
occurredAt: OCCURRED_AT,
|
||||
agentExecutionId: AGENT_EXECUTION_ID,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
message: AGENT_ERROR_MESSAGE,
|
||||
occurredAt: OCCURRED_AT,
|
||||
agentExecutionId: AGENT_EXECUTION_ID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastAgentErrorDismissKey", () => {
|
||||
it("includes both timestamp and message in the dismissal stamp", () => {
|
||||
expect(
|
||||
lastAgentErrorDismissKey("session-1", {
|
||||
message: AGENT_ERROR_MESSAGE,
|
||||
occurredAt: OCCURRED_AT,
|
||||
}),
|
||||
).toBe(`kandev:last-agent-error-dismissed:session-1:${OCCURRED_AT}:${AGENT_ERROR_MESSAGE}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export type LastAgentError = {
|
||||
message: string;
|
||||
occurredAt?: string;
|
||||
agentExecutionId?: string;
|
||||
};
|
||||
|
||||
export function readLastAgentError(metadata: Record<string, unknown> | null | undefined) {
|
||||
if (!metadata) return null;
|
||||
const raw = metadata.last_agent_error;
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const message = typeof record.message === "string" ? record.message : "";
|
||||
if (!message) return null;
|
||||
return {
|
||||
message,
|
||||
occurredAt: readOptionalString(record.occurred_at) ?? readOptionalString(record.occurredAt),
|
||||
agentExecutionId:
|
||||
readOptionalString(record.agent_execution_id) ?? readOptionalString(record.agentExecutionId),
|
||||
} satisfies LastAgentError;
|
||||
}
|
||||
|
||||
export function lastAgentErrorDismissKey(sessionId: string, error: LastAgentError) {
|
||||
const stamp = `${error.occurredAt ?? ""}:${error.message}`;
|
||||
return `kandev:last-agent-error-dismissed:${sessionId}:${stamp}`;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown) {
|
||||
return typeof value === "string" && value !== "" ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { TaskSession } from "@/lib/types/http";
|
||||
import { sessionId, taskId } from "@/lib/types/http";
|
||||
import { agentErrorMessageForTask } from "./task-agent-error";
|
||||
|
||||
function session(overrides: Partial<TaskSession>): TaskSession {
|
||||
return {
|
||||
id: sessionId("session-1"),
|
||||
task_id: taskId("task-1"),
|
||||
state: "WAITING_FOR_INPUT",
|
||||
started_at: "2026-06-14T10:00:00Z",
|
||||
updated_at: "2026-06-14T10:00:00Z",
|
||||
...overrides,
|
||||
} as TaskSession;
|
||||
}
|
||||
|
||||
function errorMetadata(message: string) {
|
||||
return {
|
||||
last_agent_error: {
|
||||
message,
|
||||
occurred_at: "2026-06-14T10:00:00Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("agentErrorMessageForTask", () => {
|
||||
it("uses the explicit primary session even when it is terminal", () => {
|
||||
const primary = session({
|
||||
id: sessionId("primary"),
|
||||
state: "COMPLETED",
|
||||
metadata: errorMetadata("primary error"),
|
||||
});
|
||||
|
||||
expect(
|
||||
agentErrorMessageForTask(
|
||||
{ id: "task-1", primarySessionId: "primary" },
|
||||
{ primary },
|
||||
{ "task-1": [] },
|
||||
),
|
||||
).toBe("primary error");
|
||||
});
|
||||
|
||||
it("ignores stale terminal sessions in the fallback path", () => {
|
||||
const oldFailed = session({
|
||||
id: sessionId("old-failed"),
|
||||
state: "FAILED",
|
||||
updated_at: "2026-06-14T12:00:00Z",
|
||||
metadata: errorMetadata("old failure"),
|
||||
});
|
||||
const current = session({
|
||||
id: sessionId("current"),
|
||||
state: "WAITING_FOR_INPUT",
|
||||
updated_at: "2026-06-14T11:00:00Z",
|
||||
metadata: errorMetadata("current failure"),
|
||||
});
|
||||
|
||||
expect(
|
||||
agentErrorMessageForTask(
|
||||
{ id: "task-1" },
|
||||
{ "old-failed": oldFailed, current },
|
||||
{ "task-1": [oldFailed, current] },
|
||||
),
|
||||
).toBe("current failure");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TaskSession } from "@/lib/types/http";
|
||||
import { readLastAgentError } from "@/lib/session-last-agent-error";
|
||||
import { isTerminalSessionState } from "@/lib/ws/handlers/agent-session";
|
||||
|
||||
export function agentErrorMessageForTask(
|
||||
task: { id: string; primarySessionId?: string | null },
|
||||
sessionsById: Record<string, TaskSession>,
|
||||
sessionsByTaskId: Record<string, TaskSession[]>,
|
||||
): string | null {
|
||||
if (task.primarySessionId) {
|
||||
const primaryError = readLastAgentError(sessionsById[task.primarySessionId]?.metadata);
|
||||
if (primaryError) return primaryError.message;
|
||||
}
|
||||
const fallbackSessions = [...(sessionsByTaskId[task.id] ?? [])]
|
||||
.filter((session) => !isTerminalSessionState(session.state))
|
||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
||||
for (const session of fallbackSessions) {
|
||||
const error = readLastAgentError(session.metadata);
|
||||
if (error) return error.message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -275,6 +275,7 @@ export type TaskSessionStateChangedPayload = {
|
||||
agent_profile_id?: string;
|
||||
agent_profile_snapshot?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
session_metadata?: Record<string, unknown>;
|
||||
is_passthrough?: boolean;
|
||||
error_message?: string;
|
||||
/** When true, the frontend should not show an error toast for this state change. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { registerTaskSessionHandlers } from "./agent-session";
|
||||
import type { StoreApi } from "zustand";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { TaskSessionStateChangedPayload } from "@/lib/types/backend";
|
||||
|
||||
function makeStore(overrides: Record<string, unknown> = {}) {
|
||||
const state: Record<string, unknown> = {
|
||||
@@ -18,6 +19,7 @@ function makeStore(overrides: Record<string, unknown> = {}) {
|
||||
upsertTaskSessionFromEvent: vi.fn(),
|
||||
setActiveSession: vi.fn(),
|
||||
setActiveSessionAuto: vi.fn(),
|
||||
setSessionAgentctlStatus: vi.fn(),
|
||||
setSessionFailureNotification: vi.fn(),
|
||||
setContextWindow: vi.fn(),
|
||||
...overrides,
|
||||
@@ -32,9 +34,16 @@ function makeStore(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
const STATE_CHANGED_EVENT = "session.state_changed";
|
||||
const RECOVERABLE_ERROR_MESSAGE = "peer disconnected before response";
|
||||
const RECOVERABLE_ERROR_AT = "2026-06-14T14:06:40Z";
|
||||
|
||||
function makeMessage(payload: Record<string, unknown>) {
|
||||
return { id: "msg-1", type: "notification", action: STATE_CHANGED_EVENT, payload };
|
||||
function makeMessage(payload: TaskSessionStateChangedPayload) {
|
||||
return {
|
||||
id: "msg-1",
|
||||
type: "notification" as const,
|
||||
action: "session.state_changed" as const,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("session.state_changed handler", () => {
|
||||
@@ -131,6 +140,48 @@ describe("session.state_changed handler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.state_changed recoverable errors", () => {
|
||||
it("upserts recoverable error metadata for non-failed session states", () => {
|
||||
const upsertTaskSessionFromEvent = vi.fn();
|
||||
const store = makeStore({
|
||||
taskSessions: {
|
||||
items: { "s-1": { id: "s-1", task_id: "t-1", state: "RUNNING" } },
|
||||
},
|
||||
upsertTaskSessionFromEvent,
|
||||
});
|
||||
const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
|
||||
|
||||
handler(
|
||||
makeMessage({
|
||||
task_id: "t-1",
|
||||
session_id: "s-1",
|
||||
new_state: "WAITING_FOR_INPUT",
|
||||
error_message: RECOVERABLE_ERROR_MESSAGE,
|
||||
session_metadata: {
|
||||
last_agent_error: {
|
||||
message: RECOVERABLE_ERROR_MESSAGE,
|
||||
occurred_at: RECOVERABLE_ERROR_AT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(upsertTaskSessionFromEvent).toHaveBeenCalledWith(
|
||||
"t-1",
|
||||
expect.objectContaining({
|
||||
state: "WAITING_FOR_INPUT",
|
||||
error_message: RECOVERABLE_ERROR_MESSAGE,
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: RECOVERABLE_ERROR_MESSAGE,
|
||||
occurred_at: RECOVERABLE_ERROR_AT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.state_changed stale guard", () => {
|
||||
let store: ReturnType<typeof makeStore>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
Reference in New Issue
Block a user