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");