fix: don't collapse a genuine clarification/permission wait to REVIEW (#3549)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Nir Levanon
2026-09-09 23:46:28 +03:00
committed by GitHub
parent e381a589b3
commit f650a157e3
9 changed files with 83 additions and 9 deletions
+12 -6
View File
@@ -103,15 +103,21 @@ export function KanbanTaskShell({
// background-running task reads distinctly and never as done,
// and carry the sidebar's rich "needs me" reading — pending clarification /
// permission — so the header distinguishes waiting-for-input.
function simpleTaskPendingFallback(task: Task | null) {
if (!task) return {};
return {
taskId: task.id,
taskPendingAction: task.task_pending_action,
statusSummary: task.status_summary,
primarySessionState: task.primary_session_state,
primarySessionPendingAction: task.primary_session_pending_action,
};
}
function simpleTaskHeaderData(task: Task | null) {
return {
primarySessionId: task?.primary_session_id,
pendingFallback: {
taskId: task?.id,
taskPendingAction: task?.task_pending_action,
primarySessionState: task?.primary_session_state,
primarySessionPendingAction: task?.primary_session_pending_action,
},
pendingFallback: simpleTaskPendingFallback(task),
identifier: task?.id?.slice(0, 8),
title: task?.title ?? t("tasks:loading"),
state: task?.state ?? null,
@@ -171,6 +171,7 @@ export function TaskListRowPrimaryContent({
const pendingInput = useTaskPendingInput(task.primary_session_id, {
taskId: task.id,
taskPendingAction: task.task_pending_action,
statusSummary: task.status_summary,
primarySessionState: task.primary_session_state,
primarySessionPendingAction: task.primary_session_pending_action,
});
@@ -368,6 +368,7 @@ function KanbanCardActions({
const pendingInput = useTaskPendingInput(task.primarySessionId, {
taskId: task.id,
taskPendingAction: task.taskPendingAction,
statusSummary: task.statusSummary,
primarySessionState: task.primarySessionState,
primarySessionPendingAction: task.primarySessionPendingAction,
});
@@ -126,6 +126,7 @@ export function Graph2StepNode({
const pendingInput = useTaskPendingInput(task.primarySessionId, {
taskId: task.id,
taskPendingAction: task.taskPendingAction,
statusSummary: task.statusSummary,
primarySessionState: task.primarySessionState,
primarySessionPendingAction: task.primarySessionPendingAction,
});
@@ -80,6 +80,7 @@ function DraggableTaskChip({
const pendingInput = useTaskPendingInput(task.primarySessionId, {
taskId: task.id,
taskPendingAction: task.taskPendingAction,
statusSummary: task.statusSummary,
primarySessionState: task.primarySessionState,
primarySessionPendingAction: task.primarySessionPendingAction,
});
@@ -117,6 +118,7 @@ function TaskChipPreview({ task }: { task: Task }) {
const pendingInput = useTaskPendingInput(task.primarySessionId, {
taskId: task.id,
taskPendingAction: task.taskPendingAction,
statusSummary: task.statusSummary,
primarySessionState: task.primarySessionState,
primarySessionPendingAction: task.primarySessionPendingAction,
});
@@ -193,6 +193,22 @@ describe("useTaskPendingInput", () => {
});
});
describe("useTaskPendingInput task status summary authority", () => {
it("prefers the task status summary over a stale legacy task snapshot", () => {
const { result } = renderHook(
() =>
useTaskPendingInput("session-1", {
taskId: "task-1",
taskPendingAction: "clarification",
statusSummary: { pending_action: "permission" },
} as Parameters<typeof useTaskPendingInput>[1]),
{ wrapper: wrapper() },
);
expect(result.current).toEqual({ clarification: false, permission: true });
});
});
describe("useTaskPendingInput pending clarification turn authority", () => {
it("preserves pending clarification while session state authority is still loading", () => {
const olderTurn = turn("turn-older", PRIMARY_SESSION_ID, BASE_TIMESTAMP);
+11 -2
View File
@@ -15,6 +15,7 @@ const NONE: PendingInput = { clarification: false, permission: false };
export type PendingInputFallback = {
taskId?: string | null;
taskPendingAction?: TaskPendingAction | null;
statusSummary?: { pending_action?: TaskPendingAction | null } | null;
primarySessionState?: string | null;
primarySessionPendingAction?: TaskPendingAction | null;
};
@@ -39,6 +40,14 @@ function actionFlags(action: TaskPendingAction | null | undefined): PendingInput
return { clarification: action === "clarification", permission: action === "permission" };
}
function fallbackTaskPendingAction(
fallback: PendingInputFallback | undefined,
): TaskPendingAction | null | undefined {
return fallback?.statusSummary != null
? fallback.statusSummary.pending_action
: fallback?.taskPendingAction;
}
function loadedSessionFlags(
messagesBySession: Record<string, Message[] | undefined>,
turnsBySession: Record<string, readonly Turn[] | undefined>,
@@ -122,7 +131,7 @@ function selectFlagsFromLoadedTaskSessions(
session.pending_action,
);
},
fallback?.taskPendingAction,
fallbackTaskPendingAction(fallback),
);
if (!result.hasUnloadedMessages) {
return { clarification: result.clarification, permission: result.permission };
@@ -139,7 +148,7 @@ function selectFlagsFromPrimarySession(
primarySession: PrimarySessionProjection,
fallback: PendingInputFallback | undefined,
): PendingInput {
const taskSnapshot = actionFlags(fallback?.taskPendingAction);
const taskSnapshot = actionFlags(fallbackTaskPendingAction(fallback));
if (taskSnapshot.clarification || taskSnapshot.permission) return taskSnapshot;
if (!primarySession.id) return NONE;
if (messagesBySession[primarySession.id] !== undefined) {
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { needsAction } from "./needs-action";
describe("needsAction", () => {
it("uses the task status summary when legacy pending data is stale", () => {
expect(
needsAction({
state: "REVIEW",
taskPendingAction: "clarification",
statusSummary: { pending_action: "permission" },
}),
).toBe(true);
});
it("recognizes a pending action when the task remains in REVIEW", () => {
expect(
needsAction({
state: "REVIEW",
statusSummary: { pending_action: "clarification" },
}),
).toBe(true);
});
it("does not revive a legacy action after the status summary clears it", () => {
expect(
needsAction({
state: "REVIEW",
taskPendingAction: "clarification",
statusSummary: {},
}),
).toBe(false);
});
});
+6 -1
View File
@@ -1,4 +1,4 @@
import type { TaskState } from "@/lib/types/http";
import type { TaskPendingAction, TaskState } from "@/lib/types/http";
/**
* Determines if a task needs user attention/action.
@@ -6,11 +6,16 @@ import type { TaskState } from "@/lib/types/http";
*/
export function needsAction(task: {
state?: TaskState;
taskPendingAction?: TaskPendingAction | null;
statusSummary?: { pending_action?: TaskPendingAction | null } | null;
reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null;
}): boolean {
const pendingAction =
task.statusSummary != null ? task.statusSummary.pending_action : task.taskPendingAction;
return (
(task.reviewStatus === "pending" && task.state !== "IN_PROGRESS") ||
task.reviewStatus === "changes_requested" ||
pendingAction != null ||
task.state === "WAITING_FOR_INPUT"
);
}