FN-6590: inject task-detail chat into active step sessions

Ensure task-detail comments are delivered to live executor threads and preserved for the next step prompt when no step session is active.

- Forward steering comments through legacy, step-session, and workflow-step executor targets with delivery status logging.
- Keep step-session task details updated and include pending steering comments in full and reduced step prompts.
- Track delivered steering comment IDs so comments are injected or queued exactly once across active and subsequent step sessions.
- Update step-session executor tests for live steering, queued prompt fallback, and reduced prompt behavior.

Files changed:
 .../src/__tests__/executor-step-session.test.ts    | 467 ++++++---------------
 .../src/__tests__/step-session-executor.test.ts    |  63 ++-
 packages/engine/src/executor.ts                    |  34 +-
 packages/engine/src/step-session-executor.ts       |  69 ++-
 4 files changed, 283 insertions(+), 350 deletions(-)

Fusion-Task-Id: FN-6590

Fusion-Task-Lineage: 18fffd41-7632-4f29-8721-daaf3c239a74
This commit is contained in:
gsxdsm
2026-06-17 14:08:54 -07:00
parent 556ddd4491
commit 0cc557121c
4 changed files with 288 additions and 355 deletions

View File

@@ -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<typeof vi.fn>, seenSteeringIds = new Set<string>()) {
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<void>(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<void>(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<void>(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,
);
});
});

View File

@@ -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: [

View File

@@ -2591,7 +2591,7 @@ export class TaskExecutor {
const injectionTargets: Array<{
kind: "legacy" | "step-session" | "workflow-step";
seenSteeringIds: Set<string>;
inject: (message: string) => Promise<void>;
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)) {

View File

@@ -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<string>();
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<void> {
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<number> {
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<void> {
@@ -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) {