feat(FN-3905): use merger session model for post-merge workflow steps

The merge implements post-merge model resolution, using the merger's own session model for executing post-merge prompt workflow steps instead of falling back to executor model defaults. Tests cover the resolution hierarchy, and documentation clarifies the precedence order for downstream consumers.

Fusion-Task-Id: FN-3905
This commit is contained in:
Fusion
2026-05-11 09:19:57 -07:00
committed by gsxdsm
parent 758073c799
commit 3c2f1bdff0
13 changed files with 379 additions and 46 deletions

View File

@@ -5196,6 +5196,159 @@ describe("aiMergeTask — post-merge workflow steps", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("uses assigned agent runtime model for post-merge prompt step when workflow step has no override", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check merged code.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
assignedAgentId: "agent-001",
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: {
listAgents: vi.fn().mockResolvedValue([]),
getAgent: vi.fn().mockResolvedValue({
id: "agent-001",
runtimeConfig: {
model: "anthropic/claude-3-5-sonnet-20241022",
},
}),
} as any,
});
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall?.[0]?.defaultProvider).toBe("anthropic");
expect(postMergeAgentCall?.[0]?.defaultModelId).toBe("claude-3-5-sonnet-20241022");
});
it("uses workflow-step model override over assigned agent runtime model", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check merged code.",
phase: "post-merge",
mode: "prompt",
modelProvider: "openai",
modelId: "gpt-4.1-mini",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
assignedAgentId: "agent-001",
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: {
listAgents: vi.fn().mockResolvedValue([]),
getAgent: vi.fn().mockResolvedValue({
id: "agent-001",
runtimeConfig: {
model: "anthropic/claude-3-5-sonnet-20241022",
},
}),
} as any,
});
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall?.[0]?.defaultProvider).toBe("openai");
expect(postMergeAgentCall?.[0]?.defaultModelId).toBe("gpt-4.1-mini");
const modelLogCall = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.find(
(call: any) => String(call[1]).includes("Workflow step 'Post-merge Notify' using model:"),
);
expect(modelLogCall?.[1]).toContain("(workflow step override)");
});
it("falls back to project default override model when no workflow-step or assigned-agent model is set", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check merged code.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o-mini",
defaultProvider: "anthropic",
defaultModelId: "claude-3-5-haiku-latest",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall?.[0]?.defaultProvider).toBe("openai");
expect(postMergeAgentCall?.[0]?.defaultModelId).toBe("gpt-4o-mini");
});
it("does not run pre-merge workflow steps in merger", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({

View File

@@ -36,7 +36,6 @@ import {
getTaskMergeBlocker,
normalizeMergeConflictStrategy,
resolveTaskMergeTarget,
resolveProjectDefaultModel,
resolveTitleSummarizerSettingsModel,
resolveAgentPrompt,
summarizeCommitBody,
@@ -7741,28 +7740,6 @@ If issues are found that need attention, describe them clearly and include concr
});
try {
const defaultModel = resolveProjectDefaultModel(settings);
const stepProvider = workflowStep.modelProvider || defaultModel.provider;
const stepModelId = workflowStep.modelId || defaultModel.modelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
// Post-merge step agents inherit merger instructions
let postMergeInstructions = "";
if (mergeOptions.agentStore) {
try {
const agents = await mergeOptions.agentStore.listAgents({ role: "merger" });
for (const agent of agents) {
if (agent.instructionsText || agent.instructionsPath) {
postMergeInstructions = await resolveAgentInstructions(agent, rootDir);
break;
}
}
} catch {
// Graceful fallback
}
}
const postMergeSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, postMergeInstructions);
// Build skill selection context for post-merge session
let postMergeSkillContext = undefined;
let taskForSkillContext: Awaited<ReturnType<typeof store.getTask>> | null = null;
@@ -7788,6 +7765,28 @@ If issues are found that need attention, describe them clearly and include concr
const assignedAgent = assignedAgentId && agentStoreWithGetAgent
? await agentStoreWithGetAgent.getAgent(assignedAgentId).catch(() => null)
: null;
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
const stepProvider = workflowStep.modelProvider || mergerSessionModel.provider;
const stepModelId = workflowStep.modelId || mergerSessionModel.modelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
// Post-merge step agents inherit merger instructions
let postMergeInstructions = "";
if (mergeOptions.agentStore) {
try {
const agents = await mergeOptions.agentStore.listAgents({ role: "merger" });
for (const agent of agents) {
if (agent.instructionsText || agent.instructionsPath) {
postMergeInstructions = await resolveAgentInstructions(agent, rootDir);
break;
}
}
} catch {
// Graceful fallback
}
}
const postMergeSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, postMergeInstructions);
const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",