Files
kandev/apps/web/lib/ui/state-icons.test.tsx
T
João Salavisa f8654880f3 fix(kanban): stop stuck spinner by writing task.state=REVIEW on engine on_turn_complete (#1001)
* fix(web): stop kanban spinner when primary session is terminal

The kanban card spinner was gated only on `task.state` being IN_PROGRESS
or SCHEDULING. Workflows leave a task in the IN_PROGRESS column after
the agent's session reaches a terminal state (COMPLETED/FAILED/CANCELLED)
so the user can review and move it manually, which made the loading
icon spin indefinitely.

The frontend already receives `primary_session_state` over SSR and WS;
gate the spinner on that not being terminal. Fixes #985.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(orchestrator): write task.state=REVIEW after engine on_turn_complete transition

The non-engine path (setSessionWaitingForInput → writeTaskReviewState) has
always written tasks.state = REVIEW when the agent's session paused for
input. The workflow-engine on_turn_complete path forgot the task-state
write: it persisted the new workflow step + flipped the session to
WAITING_FOR_INPUT but left tasks.state stale at IN_PROGRESS. That left
the kanban card stuck in IN_PROGRESS — with a spinning loader — even
after the workflow had moved it into the "Review" column.

Mirror the non-engine path: call writeTaskReviewState inside the
RUNNING/STARTING branch of applyEngineTransition. Add a regression test
that asserts tasks.state = REVIEW after an engine-driven on_turn_complete
transition.

Also tighten the frontend gate added in the previous commit to suppress
the spinner for any non-active primary-session state (WAITING_FOR_INPUT,
IDLE in addition to COMPLETED/FAILED/CANCELLED). The backend fix is the
real correction; the broader frontend gate stays as defense-in-depth
against future state-sync drift.

Refs #985.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(orchestrator): drop redundant time.Sleep in engine on_turn_complete test

writeTaskReviewState and ApplyTransition both run synchronously inside
applyEngineTransition (before processOnEnter's goroutine), so the
task-state and step assertions need no async wait. assertSessionState
already has its own 500ms polling loop for the WAITING_FOR_INPUT flip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:58:14 +01:00

69 lines
3.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { isValidElement, type ReactNode } from "react";
import { IconCheck, IconMessageQuestion } from "@tabler/icons-react";
import { getTaskStateIcon, shouldShowTaskRunningSpinner } from "./state-icons";
function iconType(node: ReactNode) {
if (!isValidElement(node)) throw new Error("Expected React element");
return node.type;
}
describe("getTaskStateIcon", () => {
it("uses the question icon for waiting-for-input task state", () => {
expect(iconType(getTaskStateIcon("WAITING_FOR_INPUT"))).toBe(IconMessageQuestion);
});
it("uses the question icon when there is a pending clarification", () => {
expect(iconType(getTaskStateIcon("REVIEW", undefined, true))).toBe(IconMessageQuestion);
});
it("keeps review task state as the review check without pending clarification", () => {
expect(iconType(getTaskStateIcon("REVIEW", undefined, false))).toBe(IconCheck);
});
});
describe("shouldShowTaskRunningSpinner", () => {
it("returns false for non-loading task states regardless of session state", () => {
expect(shouldShowTaskRunningSpinner("COMPLETED")).toBe(false);
expect(shouldShowTaskRunningSpinner("FAILED")).toBe(false);
expect(shouldShowTaskRunningSpinner("CANCELLED")).toBe(false);
expect(shouldShowTaskRunningSpinner("REVIEW")).toBe(false);
expect(shouldShowTaskRunningSpinner("TODO")).toBe(false);
expect(shouldShowTaskRunningSpinner("REVIEW", "RUNNING")).toBe(false);
});
it("returns true for SCHEDULING with no primary session yet", () => {
expect(shouldShowTaskRunningSpinner("SCHEDULING")).toBe(true);
expect(shouldShowTaskRunningSpinner("SCHEDULING", null)).toBe(true);
expect(shouldShowTaskRunningSpinner("SCHEDULING", undefined)).toBe(true);
});
it("returns true for IN_PROGRESS when the primary session is actively running", () => {
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", "RUNNING")).toBe(true);
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", "STARTING")).toBe(true);
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", "CREATED")).toBe(true);
});
it("returns true for IN_PROGRESS when no primary session is attached yet", () => {
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", undefined)).toBe(true);
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", null)).toBe(true);
});
it("suppresses the spinner when the primary session is terminal", () => {
// Repro from issue #985: agent finishes (session → COMPLETED) but the
// workflow leaves the task in IN_PROGRESS for review/manual move. The
// spinner must not keep spinning forever.
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", "COMPLETED")).toBe(false);
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", "FAILED")).toBe(false);
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", "CANCELLED")).toBe(false);
expect(shouldShowTaskRunningSpinner("SCHEDULING", "COMPLETED")).toBe(false);
});
it("suppresses the spinner when the primary session is paused (waiting/idle)", () => {
// Same desync class, paused branch: agent stopped to wait for input or
// was torn down (office IDLE). The spinner is misleading.
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", "WAITING_FOR_INPUT")).toBe(false);
expect(shouldShowTaskRunningSpinner("IN_PROGRESS", "IDLE")).toBe(false);
});
});