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:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user