diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 35e87a0c24..09337978ab 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -332,9 +332,14 @@ describe("TaskChatTab", () => { expect(mockedUseAgentLogs).toHaveBeenCalledWith("FN-001", false, "project-1"); }); - it("renders empty state when no agent output exists", () => { + it("renders empty state without timestamp shells when no transcript messages exist", () => { render(); - expect(screen.getByText(/No agent output yet/)).toBeTruthy(); + + const transcript = screen.getByTestId("task-chat-transcript"); + expect(within(transcript).getByText(/No agent output yet/)).toBeTruthy(); + expect(within(transcript).queryByTestId("task-chat-group-time")).not.toBeInTheDocument(); + expect(within(transcript).queryByTestId("task-chat-user-time")).not.toBeInTheDocument(); + expect(transcript).not.toHaveTextContent(/NaN|Invalid Date/); }); it("renders the collapsed icon-only expand toggle inside the chat view and calls the toggle handler", () => { @@ -461,6 +466,65 @@ describe("TaskChatTab", () => { expect(within(groupMeta as HTMLElement).getByTestId("task-chat-group-time")).toHaveTextContent("2m ago"); }); + it("keeps agent and user timestamp parity in the inline chat surface", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z")); + mockLogs([ + makeEntry({ agent: "executor", text: "inline agent response", timestamp: "2026-06-17T14:58:30.000Z" }), + ]); + + render( + , + ); + + const transcript = screen.getByTestId("task-chat-transcript"); + expect(within(transcript).getByText("inline agent response")).toBeVisible(); + expect(within(transcript).getByText("inline user guidance")).toBeVisible(); + expect(within(transcript).getByTestId("task-chat-group-time")).toHaveTextContent("1m ago"); + expect(within(transcript).getByTestId("task-chat-user-time")).toHaveTextContent("3m ago"); + }); + + it("keeps agent and user timestamp parity in the expanded chat surface", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z")); + mockLogs([ + makeEntry({ agent: "executor", text: "expanded single entry", timestamp: "2026-06-17T14:59:30.000Z" }), + makeEntry({ agent: "reviewer", text: "expanded older reviewer entry", timestamp: "2026-06-17T14:53:00.000Z" }), + makeEntry({ agent: "reviewer", text: "expanded latest reviewer entry", timestamp: "2026-06-17T14:55:00.000Z" }), + ]); + + render( + , + ); + + const executorMeta = screen.getByLabelText("Executor messages").querySelector(".task-chat-group-meta"); + const reviewerMeta = screen.getByLabelText("Reviewer messages").querySelector(".task-chat-group-meta"); + const userHeader = screen.getByText("You").closest(".task-chat-user-header"); + expect(executorMeta).not.toBeNull(); + expect(reviewerMeta).not.toBeNull(); + expect(userHeader).not.toBeNull(); + expect(within(executorMeta as HTMLElement).getByText("1 entry")).toBeVisible(); + expect(within(executorMeta as HTMLElement).getByTestId("task-chat-group-time")).toHaveTextContent("just now"); + expect(within(reviewerMeta as HTMLElement).getByText("2 entries")).toBeVisible(); + expect(within(reviewerMeta as HTMLElement).getByTestId("task-chat-group-time")).toHaveTextContent("5m ago"); + expect(within(userHeader as HTMLElement).getByTestId("task-chat-user-time")).toHaveTextContent("3m ago"); + }); + it("renders a single text entry as one text bubble", () => { mockLogs([ makeEntry({ agent: "executor", text: "single response" }), @@ -2100,6 +2164,17 @@ describe("TaskChatTab", () => { expect(mobileInputRule).toContain("min-height: calc(var(--space-2xl) + var(--space-lg))"); }); + it("keeps TaskDetailModal inline and expanded chat on the canonical TaskChatTab renderer", () => { + const source = readFileSync(resolve(__dirname, "../TaskDetailModal.tsx"), "utf8"); + const taskChatMounts = source.match(/ { const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); const groupMetaRule = getCssRuleBlock(css, ".task-chat-group-meta"); diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index e82fcec27e..91cf008167 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path"; import { execFileSync } from "node:child_process"; import { createHmac } from "node:crypto"; import { createApiRoutes } from "../routes.js"; +import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js"; import { getProjectIdFromRequest as getProjectIdFromRouteRequest, getProjectContext as resolveRouteProjectContext, @@ -328,6 +329,141 @@ describe("POST /tasks/:id/steer", () => { return app; } + it("passes the new steering comment id to the task-workflow wake dependency", async () => { + const scopedStore = createMockStore(); + const updatedTask = { + ...FAKE_TASK_DETAIL, + id: "FN-001", + column: "in-progress" as const, + assignedAgentId: "agent-1", + steeringComments: [{ id: "steer-route-1", text: "Please continue", author: "user" as const, createdAt: "2026-06-12T00:00:00.000Z" }], + }; + const triggerCommentWakeForAssignedAgent = vi.fn().mockResolvedValue(undefined); + (scopedStore.addSteeringComment as ReturnType).mockResolvedValue(updatedTask); + const router = express.Router(); + registerTaskWorkflowRoutes({ + router, + store: scopedStore, + runtimeLogger: { error: vi.fn(), warn: vi.fn() }, + planningLogger: { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as any, + chatLogger: { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as any, + getProjectIdFromRequest: () => undefined, + getScopedStore: async () => scopedStore, + getProjectContext: async () => ({ store: scopedStore, projectId: undefined }), + prioritizeProjectsForCurrentDirectory: (projects) => projects, + emitRemoteRouteDiagnostic: vi.fn(), + emitAuthSyncAuditLog: vi.fn(), + parseScopeParam: () => undefined, + resolveAutomationStore: vi.fn() as any, + resolveRoutineStore: vi.fn() as any, + resolveRoutineRunner: vi.fn() as any, + registerDispose: vi.fn(), + dispose: vi.fn(), + rethrowAsApiError: (error) => { throw error; }, + }, { + runtimeLogger: { error: vi.fn(), warn: vi.fn() }, + upload: { single: vi.fn(() => (_req: unknown, _res: unknown, next: () => void) => next()) }, + taskDetailActivityLogLimit: 100, + validateOptionalModelField: () => undefined, + normalizeModelSelectionPair: (provider, modelId) => ({ provider, modelId }), + runGitCommand: vi.fn(), + isGitRepo: vi.fn(), + resolveIntegrationBranch: vi.fn(), + trimTaskDetailActivityLog: (task) => task, + triggerCommentWakeForAssignedAgent, + resolveSelfHealingManager: () => undefined, + }); + const app = express(); + app.use(express.json()); + app.use("/api", router); + + const res = await REQUEST(app, "POST", "/api/tasks/FN-001/steer", JSON.stringify({ text: "Please continue" }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(scopedStore.addSteeringComment).toHaveBeenCalledWith("FN-001", "Please continue", "user"); + expect(triggerCommentWakeForAssignedAgent).toHaveBeenCalledOnce(); + expect(triggerCommentWakeForAssignedAgent).toHaveBeenCalledWith(scopedStore, updatedTask, { + triggeringCommentType: "steering", + triggeringCommentIds: ["steer-route-1"], + triggerDetail: "steering-comment", + }); + }); + + it("uses the newest steering comment id when waking assigned agents", async () => { + const scopedStore = createMockStore(); + const updatedTask = { + ...FAKE_TASK_DETAIL, + id: "FN-001", + column: "in-progress" as const, + assignedAgentId: "agent-1", + steeringComments: [ + { + id: "older-steer", + text: "Earlier guidance", + author: "user" as const, + createdAt: "2026-06-12T00:00:00.000Z", + }, + { + id: "newest-steer", + text: "Newest guidance", + author: "user" as const, + createdAt: "2026-06-12T00:01:00.000Z", + }, + ], + }; + const triggerCommentWakeForAssignedAgent = vi.fn().mockResolvedValue(undefined); + (scopedStore.addSteeringComment as ReturnType).mockResolvedValue(updatedTask); + const router = express.Router(); + registerTaskWorkflowRoutes({ + router, + store: scopedStore, + runtimeLogger: { error: vi.fn(), warn: vi.fn() }, + planningLogger: { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as any, + chatLogger: { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as any, + getProjectIdFromRequest: () => undefined, + getScopedStore: async () => scopedStore, + getProjectContext: async () => ({ store: scopedStore, projectId: undefined }), + prioritizeProjectsForCurrentDirectory: (projects) => projects, + emitRemoteRouteDiagnostic: vi.fn(), + emitAuthSyncAuditLog: vi.fn(), + parseScopeParam: () => undefined, + resolveAutomationStore: vi.fn() as any, + resolveRoutineStore: vi.fn() as any, + resolveRoutineRunner: vi.fn() as any, + registerDispose: vi.fn(), + dispose: vi.fn(), + rethrowAsApiError: (error) => { throw error; }, + }, { + runtimeLogger: { error: vi.fn(), warn: vi.fn() }, + upload: { single: vi.fn(() => (_req: unknown, _res: unknown, next: () => void) => next()) }, + taskDetailActivityLogLimit: 100, + validateOptionalModelField: () => undefined, + normalizeModelSelectionPair: (provider, modelId) => ({ provider, modelId }), + runGitCommand: vi.fn(), + isGitRepo: vi.fn(), + resolveIntegrationBranch: vi.fn(), + trimTaskDetailActivityLog: (task) => task, + triggerCommentWakeForAssignedAgent, + resolveSelfHealingManager: () => undefined, + }); + const app = express(); + app.use(express.json()); + app.use("/api", router); + + const res = await REQUEST(app, "POST", "/api/tasks/FN-001/steer", JSON.stringify({ text: "Newest guidance" }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(triggerCommentWakeForAssignedAgent).toHaveBeenCalledWith(scopedStore, updatedTask, { + triggeringCommentType: "steering", + triggeringCommentIds: ["newest-steer"], + triggerDetail: "steering-comment", + }); + }); + it("records user steering comments and wakes the assigned immediate-response agent", async () => { const updatedTask = { ...FAKE_TASK_DETAIL, diff --git a/packages/engine/src/__tests__/executor-step-session.test.ts b/packages/engine/src/__tests__/executor-step-session.test.ts index e0263db3b1..d5e75505f4 100644 --- a/packages/engine/src/__tests__/executor-step-session.test.ts +++ b/packages/engine/src/__tests__/executor-step-session.test.ts @@ -3090,7 +3090,13 @@ describe("Real-time steering injection", () => { it("injects new steering comments via active StepSessionExecutor on task:updated", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - const steerActiveSessions = vi.fn().mockResolvedValue(1); + const seenIds = new Set(); + const updateSteeringComments = vi.fn(); + const steerActiveSessions = vi.fn().mockImplementation(async () => { + expect(updateSteeringComments).toHaveBeenCalledWith([newComment]); + expect(seenIds.has(newComment.id)).toBe(true); + return 1; + }); const markSteeringCommentsDelivered = vi.fn(); const newComment = { id: "step-session-comment", @@ -3099,8 +3105,12 @@ describe("Real-time steering injection", () => { author: "user" as const, }; - (executor as any).activeStepExecutors.set("FN-001", { steerActiveSessions, markSteeringCommentsDelivered }); - (executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", new Set()); + (executor as any).activeStepExecutors.set("FN-001", { + steerActiveSessions, + markSteeringCommentsDelivered, + updateSteeringComments, + }); + (executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", seenIds); await (store as any)._triggerAsync("task:updated", { id: "FN-001", @@ -3116,6 +3126,7 @@ describe("Real-time steering injection", () => { updatedAt: new Date().toISOString(), }); + expect(updateSteeringComments).toHaveBeenCalledOnce(); expect(steerActiveSessions).toHaveBeenCalledOnce(); expect(steerActiveSessions.mock.calls[0][0]).toContain("📣 **New feedback**"); expect(steerActiveSessions.mock.calls[0][0]).toContain("Please adjust the active step"); @@ -3130,33 +3141,37 @@ describe("Real-time steering injection", () => { it("queues step-session steering comments for the next prompt when no step session is active", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - const steerActiveSessions = vi.fn().mockResolvedValue(0); - const updateSteeringComments = vi.fn(); - const markSteeringCommentsDelivered = vi.fn(); const newComment = { id: "step-session-queued-comment", text: "Please apply this in the next step prompt", createdAt: new Date().toISOString(), author: "user" as const, }; - - (executor as any).activeStepExecutors.set("FN-001", { - steerActiveSessions, - updateSteeringComments, - markSteeringCommentsDelivered, + const { StepSessionExecutor: ActualStepSessionExecutor } = await vi.importActual("../step-session-executor.js"); + const stepExecutor = new ActualStepSessionExecutor({ + taskDetail: makeSteeringTask() as any, + worktreePath: "/tmp/test", + rootDir: "/tmp", + settings: {} as any, }); + const markSteeringCommentsDelivered = vi.spyOn(stepExecutor, "markSteeringCommentsDelivered"); + + (executor as any).activeStepExecutors.set("FN-001", stepExecutor); (executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", new Set()); await (store as any)._triggerAsync("task:updated", makeSteeringTask([newComment])); - expect(steerActiveSessions).toHaveBeenCalledOnce(); - expect(updateSteeringComments).toHaveBeenCalledWith([newComment]); expect(markSteeringCommentsDelivered).not.toHaveBeenCalled(); expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("Comment received mid-execution"), "by user", ); + + const nextPromptTask = (stepExecutor as any).consumeTaskDetailForStepPrompt(); + expect(nextPromptTask.steeringComments).toEqual([newComment]); + const laterPromptTask = (stepExecutor as any).consumeTaskDetailForStepPrompt(); + expect(laterPromptTask.steeringComments).toBeUndefined(); }); it("injects new steering comments via active workflow step session on task:updated", async () => { @@ -3197,6 +3212,52 @@ describe("Real-time steering injection", () => { ); }); + it("marks new comments seen before injecting and logs once across simultaneous surfaces", async () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + const newComment = { + id: "shared-surface-comment", + text: "Please reach every live surface once", + createdAt: new Date().toISOString(), + author: "user" as const, + }; + const legacySeen = new Set(); + const stepSeen = new Set(); + const workflowSeen = new Set(); + const legacySteer = vi.fn().mockImplementation(async () => { + expect(legacySeen.has(newComment.id)).toBe(true); + }); + const stepSteerActiveSessions = vi.fn().mockImplementation(async () => { + expect(stepSeen.has(newComment.id)).toBe(true); + return 1; + }); + const workflowSteer = vi.fn().mockImplementation(async () => { + expect(workflowSeen.has(newComment.id)).toBe(true); + }); + const markSteeringCommentsDelivered = vi.fn(); + + setLegacyActiveSession(executor, legacySteer, legacySeen); + (executor as any).activeStepExecutors.set("FN-001", { steerActiveSessions: stepSteerActiveSessions, markSteeringCommentsDelivered }); + (executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", stepSeen); + (executor as any).activeWorkflowStepSessions.set("FN-001", { steer: workflowSteer }); + (executor as any).activeWorkflowStepSessionSeenSteeringIds.set("FN-001", workflowSeen); + + await (store as any)._triggerAsync("task:updated", makeSteeringTask([newComment])); + await (store as any)._triggerAsync("task:updated", makeSteeringTask([newComment])); + + expect(legacySteer).toHaveBeenCalledOnce(); + expect(stepSteerActiveSessions).toHaveBeenCalledOnce(); + expect(workflowSteer).toHaveBeenCalledOnce(); + expect(markSteeringCommentsDelivered).toHaveBeenCalledOnce(); + expect(markSteeringCommentsDelivered).toHaveBeenCalledWith([newComment.id]); + expect(store.logEntry).toHaveBeenCalledTimes(1); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-001", + expect.stringContaining("Comment received mid-execution"), + "by user", + ); + }); + it("does not re-inject an already seen active StepSessionExecutor steering comment", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); @@ -3263,6 +3324,35 @@ describe("Real-time steering injection", () => { expect(steerFn).toHaveBeenCalledTimes(1); }); + it("does not inject or log when active surfaces receive empty or undefined steering comments", async () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + const legacySteer = vi.fn().mockResolvedValue(undefined); + const stepSteerActiveSessions = vi.fn().mockResolvedValue(1); + const workflowSteer = vi.fn().mockResolvedValue(undefined); + + setLegacyActiveSession(executor, legacySteer); + (executor as any).activeStepExecutors.set("FN-001", { + steerActiveSessions: stepSteerActiveSessions, + updateSteeringComments: vi.fn(), + }); + (executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", new Set()); + (executor as any).activeWorkflowStepSessions.set("FN-001", { steer: workflowSteer }); + (executor as any).activeWorkflowStepSessionSeenSteeringIds.set("FN-001", new Set()); + + await (store as any)._triggerAsync("task:updated", { ...makeSteeringTask(), steeringComments: undefined }); + await (store as any)._triggerAsync("task:updated", makeSteeringTask([])); + + expect(legacySteer).not.toHaveBeenCalled(); + expect(stepSteerActiveSessions).not.toHaveBeenCalled(); + expect(workflowSteer).not.toHaveBeenCalled(); + expect(store.logEntry).not.toHaveBeenCalledWith( + "FN-001", + expect.stringContaining("Comment received mid-execution"), + expect.anything(), + ); + }); + it("does not inject steering comments for tasks without an active injection target", async () => { const store = createMockStore(); new TaskExecutor(store, "/tmp/test");