diff --git a/packages/engine/src/__tests__/executor-step-session.test.ts b/packages/engine/src/__tests__/executor-step-session.test.ts index db254e6a21..0006f86925 100644 --- a/packages/engine/src/__tests__/executor-step-session.test.ts +++ b/packages/engine/src/__tests__/executor-step-session.test.ts @@ -3013,22 +3013,32 @@ describe("Real-time steering injection", () => { resetExecutorMocks(); }); + function makeSteeringTask(steeringComments: Array<{ id: string; text: string; createdAt: string; author: "user" | "agent" }> = []) { + return { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + steeringComments, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + + function setLegacyActiveSession(executor: TaskExecutor, steerFn: ReturnType, seenSteeringIds = new Set()) { + const session = { steer: steerFn, dispose: vi.fn() }; + const state = { session, seenSteeringIds }; + (executor as any).activeSessions.set("FN-001", state); + return { session, state }; + } + it("initializes seenSteeringIds with existing comments at session start", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); - - // Mock session with steer method - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockImplementation(async () => { - // Simulate execution running - await new Promise(resolve => setTimeout(resolve, 10)); - }), - dispose: vi.fn(), - steer: steerFn, - }, - } as any); - const executor = new TaskExecutor(store, "/tmp/test"); const existingComment = { id: "1234567890-abc123", @@ -3036,65 +3046,18 @@ describe("Real-time steering injection", () => { createdAt: new Date().toISOString(), author: "user" as const, }; + setLegacyActiveSession(executor, steerFn, new Set([existingComment.id])); - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - steeringComments: [existingComment], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); + await (store as any)._triggerAsync("task:updated", makeSteeringTask([existingComment])); - // Wait for execution to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // No steer calls should be made for existing comments expect(steerFn).not.toHaveBeenCalled(); }); it("injects new steering comments via session.steer() on task:updated", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); - let promptResolve: () => void; - const promptPromise = new Promise(resolve => { promptResolve = resolve; }); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockImplementation(async () => { - // Wait for signal to complete - await promptPromise; - }), - dispose: vi.fn(), - steer: steerFn, - }, - } as any); - const executor = new TaskExecutor(store, "/tmp/test"); - - // Start execution - const executePromise = executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Wait for agent to start - await new Promise(resolve => setTimeout(resolve, 20)); - - // Simulate adding a steering comment mid-execution + setLegacyActiveSession(executor, steerFn); const newComment = { id: "9876543210-def456", text: "Please use a different approach", @@ -3102,44 +3065,23 @@ describe("Real-time steering injection", () => { author: "user" as const, }; - store._trigger("task:updated", { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - steeringComments: [newComment], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); + await (store as any)._triggerAsync("task:updated", makeSteeringTask([newComment])); - // Wait for steer to be called - await new Promise(resolve => setTimeout(resolve, 20)); - - // Verify steer was called with the formatted message expect(steerFn).toHaveBeenCalledOnce(); expect(steerFn.mock.calls[0][0]).toContain("📣 **New feedback**"); expect(steerFn.mock.calls[0][0]).toContain("Please use a different approach"); - - // Verify log entry was created expect(store.logEntry).toHaveBeenCalledWith( "FN-001", expect.stringContaining("Comment received mid-execution"), "by user" ); - - // Complete the execution - promptResolve!(); - await executePromise; }); 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(undefined); + const steerActiveSessions = vi.fn().mockResolvedValue(1); + const markSteeringCommentsDelivered = vi.fn(); const newComment = { id: "step-session-comment", text: "Please adjust the active step", @@ -3147,7 +3089,7 @@ describe("Real-time steering injection", () => { author: "user" as const, }; - (executor as any).activeStepExecutors.set("FN-001", { steerActiveSessions }); + (executor as any).activeStepExecutors.set("FN-001", { steerActiveSessions, markSteeringCommentsDelivered }); (executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", new Set()); await (store as any)._triggerAsync("task:updated", { @@ -3167,6 +3109,39 @@ describe("Real-time steering injection", () => { expect(steerActiveSessions).toHaveBeenCalledOnce(); expect(steerActiveSessions.mock.calls[0][0]).toContain("📣 **New feedback**"); expect(steerActiveSessions.mock.calls[0][0]).toContain("Please adjust the active step"); + expect(markSteeringCommentsDelivered).toHaveBeenCalledWith([newComment.id]); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-001", + expect.stringContaining("Comment received mid-execution"), + "by user", + ); + }); + + 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, + }); + (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"), @@ -3246,298 +3221,112 @@ describe("Real-time steering injection", () => { it("does not re-inject already seen steering comments", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - steer: steerFn, - }, - } as any); - const executor = new TaskExecutor(store, "/tmp/test"); - const commentId = "1111111111-aaa111"; - - // Start execution with one comment - await executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - steeringComments: [{ - id: commentId, - text: "Original comment", - createdAt: new Date().toISOString(), - author: "user" as const, - }], + const comment = { + id: "1111111111-aaa111", + text: "Original comment", createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); + author: "user" as const, + }; + setLegacyActiveSession(executor, steerFn, new Set([comment.id])); - // Wait for execution to start - await new Promise(resolve => setTimeout(resolve, 20)); + await (store as any)._triggerAsync("task:updated", makeSteeringTask([comment])); - // Trigger task:updated with the SAME comment (simulating a non-steering update) - store._trigger("task:updated", { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - steeringComments: [{ - id: commentId, - text: "Original comment", - createdAt: new Date().toISOString(), - author: "user" as const, - }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Wait and verify steer was not called again - await new Promise(resolve => setTimeout(resolve, 20)); expect(steerFn).not.toHaveBeenCalled(); }); it("marks comment as seen even if steer() throws", async () => { const store = createMockStore(); const steerFn = vi.fn().mockRejectedValue(new Error("Session disconnected")); - let resolvePrompt: () => void; - const promptPromise = new Promise(resolve => { resolvePrompt = resolve; }); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockImplementation(() => promptPromise), - dispose: vi.fn(), - steer: steerFn, - }, - } as any); - const executor = new TaskExecutor(store, "/tmp/test"); - const commentId = "2222222222-bbb222"; - - // Start execution (don't await yet) - const executePromise = executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - steeringComments: [], + const comment = { + id: "2222222222-bbb222", + text: "Comment that fails", createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); + author: "user" as const, + }; + setLegacyActiveSession(executor, steerFn); - // Wait for execution to start - await new Promise(resolve => setTimeout(resolve, 20)); + await (store as any)._triggerAsync("task:updated", makeSteeringTask([comment])); + await (store as any)._triggerAsync("task:updated", makeSteeringTask([comment])); - // Add a new comment that will fail to inject - store._trigger("task:updated", { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - steeringComments: [{ - id: commentId, - text: "Comment that fails", - createdAt: new Date().toISOString(), - author: "user" as const, - }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Wait for processing - await new Promise(resolve => setTimeout(resolve, 20)); - - // Verify steer was called (and failed) - expect(steerFn).toHaveBeenCalledOnce(); - - // Trigger task:updated again with the same comment - store._trigger("task:updated", { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - steeringComments: [{ - id: commentId, - text: "Comment that fails", - createdAt: new Date().toISOString(), - author: "user" as const, - }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Wait and verify steer was NOT called again (comment marked as seen) - await new Promise(resolve => setTimeout(resolve, 20)); expect(steerFn).toHaveBeenCalledTimes(1); - - // Complete execution - resolvePrompt!(); - await executePromise; }); - it("does not inject steering comments for tasks not in activeSessions", async () => { + it("does not inject steering comments for tasks without an active injection target", async () => { const store = createMockStore(); - const steerFn = vi.fn().mockResolvedValue(undefined); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - steer: steerFn, - }, - } as any); - new TaskExecutor(store, "/tmp/test"); - // Trigger task:updated for a task that is not in activeSessions - store._trigger("task:updated", { - id: "FN-NOT-EXECUTING", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - steeringComments: [{ + await (store as any)._triggerAsync("task:updated", { + ...makeSteeringTask([{ id: "3333333333-ccc333", text: "Should not be injected", createdAt: new Date().toISOString(), author: "user" as const, - }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + }]), + id: "FN-NOT-EXECUTING", }); - // Wait and verify steer was not called - await new Promise(resolve => setTimeout(resolve, 20)); - expect(steerFn).not.toHaveBeenCalled(); + expect(store.logEntry).not.toHaveBeenCalledWith( + "FN-NOT-EXECUTING", + expect.stringContaining("Comment received mid-execution"), + expect.anything(), + ); }); it("handles multiple new steering comments in a single task:updated", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); - let resolvePrompt: () => void; - const promptPromise = new Promise(resolve => { resolvePrompt = resolve; }); - - // Set up getTask to return the task with existing comment in comments (used for seenSteeringIds init) - store.getTask.mockResolvedValue({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - comments: [{ - id: "existing-comment", - text: "Original", - createdAt: new Date().toISOString(), - author: "user", - }], - steeringComments: [{ - id: "existing-comment", - text: "Original", - createdAt: new Date().toISOString(), - author: "user", - }], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockImplementation(() => promptPromise), - dispose: vi.fn(), - steer: steerFn, - }, - } as any); - const executor = new TaskExecutor(store, "/tmp/test"); + setLegacyActiveSession(executor, steerFn, new Set(["existing-comment"])); - // Start execution (don't await yet) - const executePromise = executor.execute({ - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); + await (store as any)._triggerAsync("task:updated", makeSteeringTask([ + { + id: "existing-comment", + text: "Original", + createdAt: new Date().toISOString(), + author: "user" as const, + }, + { + id: "new-comment-1", + text: "First new comment", + createdAt: new Date().toISOString(), + author: "user" as const, + }, + { + id: "new-comment-2", + text: "Second new comment", + createdAt: new Date().toISOString(), + author: "user" as const, + }, + ])); - // Wait for execution to start - await new Promise(resolve => setTimeout(resolve, 20)); - - // Add two new comments at once - store._trigger("task:updated", { - id: "FN-001", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - steeringComments: [ - { - id: "existing-comment", - text: "Original", - createdAt: new Date().toISOString(), - author: "user", - }, - { - id: "new-comment-1", - text: "First new comment", - createdAt: new Date().toISOString(), - author: "user", - }, - { - id: "new-comment-2", - text: "Second new comment", - createdAt: new Date().toISOString(), - author: "user", - }, - ], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - // Wait for processing - await new Promise(resolve => setTimeout(resolve, 20)); - - // Verify steer was called twice (once for each new comment) expect(steerFn).toHaveBeenCalledTimes(2); + }); - // Complete execution - resolvePrompt!(); - await executePromise; + it("keeps agent-authored steering on the legacy review-handoff path", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ reviewHandoffPolicy: "comment-triggered" } as any); + const steerFn = vi.fn().mockResolvedValue(undefined); + const executor = new TaskExecutor(store, "/tmp/test"); + const { session, state } = setLegacyActiveSession(executor, steerFn); + const executeReviewHandoff = vi.fn().mockResolvedValue(undefined); + (executor as any).executeReviewHandoff = executeReviewHandoff; + const agentComment = { + id: "agent-handoff-comment", + text: "Implementation is ready; requesting user review now.", + createdAt: new Date().toISOString(), + author: "agent" as const, + }; + + await (store as any)._triggerAsync("task:updated", makeSteeringTask([agentComment])); + + expect(steerFn).toHaveBeenCalledOnce(); + expect(executeReviewHandoff).toHaveBeenCalledWith( + expect.objectContaining({ id: "FN-001" }), + session, + state, + ); }); }); diff --git a/packages/engine/src/__tests__/step-session-executor.test.ts b/packages/engine/src/__tests__/step-session-executor.test.ts index e16dae7b41..41453461a7 100644 --- a/packages/engine/src/__tests__/step-session-executor.test.ts +++ b/packages/engine/src/__tests__/step-session-executor.test.ts @@ -697,6 +697,25 @@ Some freeform text without checkboxes.`; expect(result).not.toContain("Project Commands"); }); + it("includes user steering comments as next-session fallback when no active step session existed", () => { + const task = makeTaskDetail({ + prompt: fullPrompt, + steeringComments: [ + { + id: "comment-1", + author: "user", + text: "Please prioritize the API invariant before refactoring.", + createdAt: "2026-06-17T13:45:00.000Z", + }, + ], + }); + + const result = buildStepPrompt(task, 1); + + expect(result).toContain("## Steering Comments"); + expect(result).toContain("Please prioritize the API invariant before refactoring."); + }); + it("includes fn_task_done instruction at the end", () => { const task = makeTaskDetail({ prompt: fullPrompt }); const result = buildStepPrompt(task, 1); @@ -1106,8 +1125,9 @@ describe("StepSessionExecutor", () => { steer: steerThree, }); - await executor.steerActiveSessions("new guidance"); + const steeredCount = await executor.steerActiveSessions("new guidance"); + expect(steeredCount).toBe(3); expect(steerOne).toHaveBeenCalledWith("new guidance"); expect(steerTwo).toHaveBeenCalledWith("new guidance"); expect(steerThree).toHaveBeenCalledWith("new guidance"); @@ -1142,6 +1162,47 @@ describe("StepSessionExecutor", () => { ); }); + it("delivers pending steering comments in exactly one subsequent step prompt", async () => { + const prompt = makeStepPrompt("FN-001", 2); + const task = makeTaskDetail({ + prompt, + steps: [ + { name: "Step 0", status: "pending" }, + { name: "Step 1", status: "pending" }, + ], + steeringComments: [ + { + id: "queued-comment", + author: "user", + text: "Please include the queued guidance.", + createdAt: "2026-06-17T13:45:00.000Z", + }, + ], + }); + const settings = makeSettings({ maxParallelSteps: 1 }); + const prompts: string[] = []; + mockedCreateFnAgent.mockImplementation(async () => ({ + session: makeMockSession(vi.fn(async (message: string) => { + prompts.push(message); + }) as any), + }) as any); + + const executor = new StepSessionExecutor({ + taskDetail: task, + worktreePath: "/project/.worktrees/main", + rootDir: "/project", + settings, + pluginRunner: undefined, + } as any); + + const result = await executor.executeAll(); + + expect(result).toHaveLength(2); + expect(prompts[0]).toContain("## Steering Comments"); + expect(prompts[0]).toContain("Please include the queued guidance."); + expect(prompts[1]).not.toContain("Please include the queued guidance."); + }); + it("happy path: 3-step task, all steps succeed", async () => { const prompt = makeStepPrompt("FN-001", 3); const task = makeTaskDetail({ prompt, steps: [ diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index fa917753e2..ce8bc485f0 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -2591,7 +2591,7 @@ export class TaskExecutor { const injectionTargets: Array<{ kind: "legacy" | "step-session" | "workflow-step"; seenSteeringIds: Set; - inject: (message: string) => Promise; + inject: (message: string, comment: import("@fusion/core").SteeringComment) => Promise<"injected" | "queued">; legacySession?: AgentSession; legacyState?: ActiveExecutorSessionState; }> = []; @@ -2601,7 +2601,10 @@ export class TaskExecutor { injectionTargets.push({ kind: "legacy", seenSteeringIds: activeSession.seenSteeringIds, - inject: (message) => activeSession.session.steer(message), + inject: async (message) => { + await activeSession.session.steer(message); + return "injected"; + }, legacySession: activeSession.session, legacyState: activeSession, }); @@ -2609,12 +2612,24 @@ export class TaskExecutor { const stepExecutor = this.activeStepExecutors.get(task.id); if (stepExecutor) { + /* + FNXC:TaskDetailChat 2026-06-17-13:24: + Task-detail chat comments must reach the running LLM thread immediately across legacy, step-session, and workflow-step surfaces. Step-session runs can be between per-step AgentSessions when a comment arrives, so keep the executor's task snapshot current and treat zero-session fan-out as a next-prompt fallback while preserving seenSteeringIds exactly-once delivery. + */ + stepExecutor.updateSteeringComments?.(task.steeringComments); const seenSteeringIds = this.activeStepExecutorSeenSteeringIds.get(task.id) ?? this.createSeenSteeringIds(task); this.activeStepExecutorSeenSteeringIds.set(task.id, seenSteeringIds); injectionTargets.push({ kind: "step-session", seenSteeringIds, - inject: (message) => stepExecutor.steerActiveSessions(message), + inject: async (message, comment) => { + const steeredSessionCount = await stepExecutor.steerActiveSessions(message); + if (steeredSessionCount > 0) { + stepExecutor.markSteeringCommentsDelivered?.([comment.id]); + return "injected"; + } + return "queued"; + }, }); } @@ -2625,7 +2640,10 @@ export class TaskExecutor { injectionTargets.push({ kind: "workflow-step", seenSteeringIds, - inject: (message) => workflowSession.steer(message), + inject: async (message) => { + await workflowSession.steer(message); + return "injected"; + }, }); } @@ -2652,8 +2670,12 @@ export class TaskExecutor { const commentMessage = formatCommentForInjection(comment); try { executorLog.log(`Injecting comment into ${task.id} (${target.kind}): ${summary}`); - await target.inject(commentMessage); - executorLog.log(`Successfully injected comment into ${task.id} (${target.kind})`); + const delivery = await target.inject(commentMessage, comment); + if (delivery === "queued") { + executorLog.log(`Queued comment for next ${target.kind} prompt in ${task.id}`); + } else { + executorLog.log(`Successfully injected comment into ${task.id} (${target.kind})`); + } // Log to the task once per comment/tick even if multiple active surfaces exist. if (!loggedCommentIds.has(comment.id)) { diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 481adec506..f2a53bb50c 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -17,7 +17,7 @@ const execAsync = promisify(exec); import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; import type { AgentSession } from "@earendil-works/pi-coding-agent"; -import type { AgentStore, MessageStore, PermanentAgentGatingContext, TaskDetail, Settings, TaskStore } from "@fusion/core"; +import type { AgentStore, MessageStore, PermanentAgentGatingContext, TaskDetail, Settings, SteeringComment, TaskStore } from "@fusion/core"; import { resolvePersistAgentThinkingLog } from "@fusion/core"; import { @@ -403,6 +403,9 @@ export function buildStepPrompt( commandsSection = lines.join("\n") + "\n\n"; } + // Build steering comments section (last 10 comments only to avoid context bloat) + const steeringSection = buildStepSteeringCommentsSection(taskDetail.steeringComments); + // Build attachments section let attachmentsSection = ""; if (attachments && attachments.length > 0 && rootDir) { @@ -458,6 +461,10 @@ export function buildStepPrompt( parts.push(attachmentsSection); } + if (steeringSection) { + parts.push(steeringSection, ""); + } + if (isLastStep && completionSection) { parts.push(completionSection, ""); } @@ -480,6 +487,24 @@ export function buildStepPrompt( return parts.join("\n"); } +function buildStepSteeringCommentsSection(comments: SteeringComment[] | undefined): string { + if (!comments || comments.length === 0) return ""; + + const recentComments = [...comments].slice(-10); + const lines = [ + "## Steering Comments", + "", + "The following comments were added during execution. Consider adjusting your approach for this step based on this feedback.", + "", + ]; + for (const comment of recentComments) { + lines.push(`**${comment.author}** — ${new Date(comment.createdAt).toLocaleString()}`); + lines.push(`> ${comment.text}`); + lines.push(""); + } + return lines.join("\n"); +} + function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?: string): string { if (!rootDir || !worktreePath || rootDir === worktreePath || !prompt.includes(rootDir)) { return prompt; @@ -565,6 +590,7 @@ export function buildReducedStepPrompt(taskDetail: TaskDetail, stepIndex: number const stepSection = extractStepSection(prompt, stepIndex); const hasAttachments = Boolean(attachments && attachments.length > 0); const attachmentDir = rootDir ? `${rootDir}/.fusion/tasks/${id}/attachments/` : `.fusion/tasks/${id}/attachments/`; + const steeringSection = buildStepSteeringCommentsSection(taskDetail.steeringComments); // Build a minimal prompt that focuses on the step without excessive context const parts: string[] = [ @@ -579,6 +605,8 @@ export function buildReducedStepPrompt(taskDetail: TaskDetail, stepIndex: number ? `${attachments?.length ?? 0} attachment(s) available at \`${attachmentDir}\` — read the files there for context. They live at the project root and are readable even when working in a worktree.` : "", "", + steeringSection, + "", "IMPORTANT: Your previous attempt hit the context window limit.", "Do NOT repeat work that's already been done.", "Check git status and git log to see what's been committed.", @@ -657,6 +685,7 @@ export class StepSessionExecutor { private stepResults: StepResult[] = []; private aborted = false; private maxParallel: number; + private deliveredSteeringCommentIds = new Set(); private registerActiveStepSession(stepIndex: number, handle: SessionHandle, worktreePath: string): void { this.activeSessions.set(stepIndex, handle); @@ -772,7 +801,37 @@ export class StepSessionExecutor { } } - async steerActiveSessions(message: string): Promise { + updateSteeringComments(comments: SteeringComment[] | undefined): void { + this.options = { + ...this.options, + taskDetail: { + ...this.options.taskDetail, + steeringComments: comments ? [...comments] : comments, + }, + }; + } + + markSteeringCommentsDelivered(commentIds: string[]): void { + for (const commentId of commentIds) { + this.deliveredSteeringCommentIds.add(commentId); + } + } + + private consumeTaskDetailForStepPrompt(): TaskDetail { + const pendingSteeringComments = (this.options.taskDetail.steeringComments ?? []).filter( + (comment) => !this.deliveredSteeringCommentIds.has(comment.id), + ); + for (const comment of pendingSteeringComments) { + this.deliveredSteeringCommentIds.add(comment.id); + } + return { + ...this.options.taskDetail, + steeringComments: pendingSteeringComments.length > 0 ? pendingSteeringComments : undefined, + }; + } + + async steerActiveSessions(message: string): Promise { + const activeSessionCount = this.activeSessions.size; for (const [stepIdx, handle] of this.activeSessions) { try { await handle.steer(message); @@ -780,6 +839,7 @@ export class StepSessionExecutor { stepExecLog.warn(`Failed to steer active session for step ${stepIdx}: ${err}`); } } + return activeSessionCount; } async terminateAllSessions(): Promise { @@ -936,10 +996,11 @@ export class StepSessionExecutor { this.options.onStepStart?.(stepIndex); // Build step prompt - const stepPrompt = buildStepPrompt(taskDetail, stepIndex, this.options.rootDir, settings, worktreePath); + const promptTaskDetail = this.consumeTaskDetailForStepPrompt(); + const stepPrompt = buildStepPrompt(promptTaskDetail, stepIndex, this.options.rootDir, settings, worktreePath); // Build reduced step prompt for context-limit recovery (simpler, shorter) - const reducedStepPrompt = buildReducedStepPrompt(taskDetail, stepIndex, this.options.rootDir); + const reducedStepPrompt = buildReducedStepPrompt(promptTaskDetail, stepIndex, this.options.rootDir); // Acquire semaphore if provided if (semaphore) {