From 8b64aed1798759c8d2c8b406cbf13d81ceab131c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 10 Sep 2026 18:02:45 +0100 Subject: [PATCH] fix: block workflow proceed during clarification (#3574) --- .../task/chat/chat-input-area.test.ts | 25 ++++ .../task/chat/chat-input-area.test.tsx | 128 +++++++++++++++++- .../components/task/chat/chat-input-area.tsx | 6 + .../components/task/chat/chat-status-bar.tsx | 5 +- apps/web/components/task/chat/types.ts | 19 ++- .../task/passthrough-toolbar.test.tsx | 29 +++- .../components/task/passthrough-toolbar.tsx | 11 +- .../plan.md | 26 +++- ...reserve-signal-gated-proceed-visibility.md | 46 ++++++- .../workflow-explicit-completion-signal.md | 17 +++ ...low-signal-gated-manual-move-visibility.md | 36 +++-- 11 files changed, 317 insertions(+), 31 deletions(-) diff --git a/apps/web/components/task/chat/chat-input-area.test.ts b/apps/web/components/task/chat/chat-input-area.test.ts index f6a46feb3b..e8eeefbfda 100644 --- a/apps/web/components/task/chat/chat-input-area.test.ts +++ b/apps/web/components/task/chat/chat-input-area.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildSubmitMessage } from "./chat-input-area"; import { resolveStatusRowTaskId, shouldRenderChatStatusBar } from "./chat-status-bar"; +import { hasPendingClarification, shouldShowProceed } from "./types"; import type { AgentMessageComment } from "@/lib/state/slices/comments"; const messageComment: AgentMessageComment = { @@ -51,6 +52,30 @@ describe("shouldRenderChatStatusBar", () => { }); }); +describe("shouldShowProceed", () => { + it.each([ + ["not busy without a clarification", false, false, true], + ["busy without a clarification", true, false, false], + ["waiting for input with a pending clarification", false, true, false], + ])("%s", (_state, isAgentBusy, hasPendingClarification, expected) => { + expect(shouldShowProceed("Review", isAgentBusy, hasPendingClarification)).toBe(expected); + }); +}); + +describe("hasPendingClarification", () => { + it("retains the message-derived fallback", () => { + expect(hasPendingClarification(true, null)).toBe(true); + }); + + it("uses the durable session projection while messages hydrate", () => { + expect(hasPendingClarification(false, "clarification")).toBe(true); + }); + + it("does not treat a durable permission request as a clarification", () => { + expect(hasPendingClarification(false, "permission")).toBe(false); + }); +}); + describe("resolveStatusRowTaskId", () => { it("prefers the session-derived task so existing hosts are unchanged", () => { expect(resolveStatusRowTaskId("from-session", "from-host")).toBe("from-session"); diff --git a/apps/web/components/task/chat/chat-input-area.test.tsx b/apps/web/components/task/chat/chat-input-area.test.tsx index 8bfec40caf..d696ea2661 100644 --- a/apps/web/components/task/chat/chat-input-area.test.tsx +++ b/apps/web/components/task/chat/chat-input-area.test.tsx @@ -1,11 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, renderHook } from "@testing-library/react"; +import { act, cleanup, render, renderHook, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; const toastMock = vi.fn(); const handleSendMessageMock = vi.fn(); const useKeyboardShortcutMock = vi.hoisted(() => vi.fn()); +let mockProceedStepName: string | null = null; -const mockState = { userSettings: { keyboardShortcuts: {} } }; +const mockState = { + userSettings: { keyboardShortcuts: {}, chatSubmitKey: "enter" }, + quickChat: { sessions: [] }, + kanban: { workflowId: null, tasks: [], steps: [] }, + kanbanMulti: { snapshots: {} }, + workflows: { items: [] }, +}; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), @@ -41,6 +49,42 @@ vi.mock("@/components/task/chat/chat-input-container", () => ({ ChatInputContainer: () => null, })); +vi.mock("@/components/task/chat/queued-ghost-list", () => ({ + QueueAffordance: ({ + children, + renderStatusBar, + }: { + children: ReactNode; + renderStatusBar?: (queueChip: ReactNode) => ReactNode; + }) => ( + <> + {renderStatusBar?.(null)} + {children} + + ), +})); + +vi.mock("./composer-agent-start-hint", () => ({ + ComposerAgentStartHint: () => null, +})); + +vi.mock("./dynamic-route-recovery", () => ({ + DynamicRouteRecovery: () => null, +})); + +vi.mock("./chat-status-bar", () => ({ + ChatStatusBar: (props: { + nextStepName: string | null; + isAgentBusy: boolean; + hasPendingClarification: boolean; + }) => + props.nextStepName && !props.isAgentBusy && !props.hasPendingClarification ? ( +